Entities, Tables & Types
How one TypeScript interface ties a database table, its entity, and its response DTO together so they can never drift.
Each resource exists in three places: a PostgreSQL table, a TypeORM entity that maps it, and a response DTO that goes over the wire. A single plain-TypeScript interface owns the shape; the entity and the DTO both implements it, so neither can drift from the contract without a build break.
Modeling a resource is mechanical: name the interface first, then the two classes follow.
| Piece | File | Role |
|---|---|---|
| Interface | dto/<module>.types.ts | The contract — pure TS, no decorators |
| Entity | entities/<resource>.entity.ts | implements it + TypeORM column decorators |
| Response DTO | dto/<resource>.dto.ts | implements it + validator/Swagger decorators |
The contract
Lives in <module>/dto/<module>.types.ts. Pure TypeScript — no TypeORM, Swagger, or DB/HTTP imports. extends DatabaseEntity folds in the UUID id and timestamp types, exported as a type.
interface Campus extends DatabaseEntity {
name: string;
description?: string;
address: Address;
slug: string;
admissionFee?: number;
// ...
}Add, rename, or make a field optional in the interface — never edit the entity and DTO directly. The compiler then walks you through both.
Entity maps the table
implements the interface (shape is checked against the contract) and carries decorators that map each property to a column.
@Entity('campus')
@TenantScoped()
@WithTimestamps()
export class CampusEntity extends DatabaseEntity implements Campus {
@Column()
name: string;
@Column({ unique: true })
slug: string;
// ...
}@Entity('campus')names the table; each@Column()becomes a column.extends DatabaseEntitysupplies the UUIDid.@WithTimestamps()registerscreated_at/updated_at/deleted_at— applied last so they land at the table's end.@TenantScoped()is the multi-tenancy hook — see The Database.- Year-owned entities (enrollment, grade, exams…) also declare a non-null
campusAcademicYearIdcolumn — a convention, not a decorator; see Scoped Entities. - Drop a contract field and the build fails:
implementsblocks divergence.
Columns and types
Columns are snake_case in PostgreSQL, camelCase in TS. The global SnakeNamingStrategy maps the two automatically. Set name: only to override it.
@Column() options reached for constantly: nullable, unique, default, length, type. An enum discriminator tags which kind of row a record is.
@Column({ unique: true })
email: string;
@Column({ type: 'enum', enum: UserType })
type: UserType;
@Column({ name: 'is_active', default: true })
isActive: boolean;admissionFee in the class becomes admission_fee in the table automatically. Set name: only to override the strategy, never to restate it.
The shared base types
Two base types live side by side in one file:
DatabaseEntity— entity-side base. Owns the UUIDidand declares the timestamp types (@WithTimestamps()registers the real columns).DatabaseEntityDto— response-side twin.implements DatabaseEntity, decorating each field for validation and Swagger.
abstract class DatabaseEntityDto implements DatabaseEntity {
@IsUUID()
@ApiProperty({ description: 'ID' })
id: string;
@IsDate()
@ApiProperty({ description: 'Date of creation' })
createdAt: Date;
// updatedAt, deletedAt ...
}A DTO extends DatabaseEntityDto to inherit a documented, validated id and timestamps. The interface extends DatabaseEntity, so those fields are part of every contract — defined once, ride along everywhere.
DTO maps the wire
implements the interface like the entity, but carries class-validator + Swagger decorators describing the JSON shape. Same field list, different decorators. extends DatabaseEntityDto brings in id and timestamps.
export class CampusDto extends DatabaseEntityDto implements Campus {
@IsString()
@IsNotEmpty()
@ApiProperty()
name: string;
@IsNumber()
@IsOptional()
@ApiPropertyOptional({ required: false })
admissionFee?: number;
// ...
}Validator and @ApiProperty rules in depth → DTOs & Validation.
The entity and the DTO never reference each other — only the interface. So they can't agree with each other while disagreeing with the truth.
Relations in brief
Links are entity decorators: @ManyToOne, @OneToOne, @OneToMany, paired with @JoinColumn to name the foreign-key column.
@OneToOne(() => AddressEntity, (address) => address.id, {
eager: true,
onDelete: 'SET NULL',
nullable: true,
})
@JoinColumn({ name: 'address_id' })
address: AddressEntity;- Lazy relations are typed
Promise<T>and must be awaited before reading (e.g.UserEntityprofile links:{ lazy: true }, typedPromise<StaffProfileEntity>). - Many-to-many is never implicit: an explicit
<a>-x-<b>join entity holds two foreign keys with a@Uniqueon the pair.
The discriminator-plus-join-table machinery (how UserType decides which profile to load) → Module Anatomy.
Where to go next
The Database
The DatabaseEntity base, tenant scoping, soft delete, and migrations.
DTOs & Validation
The validator and Swagger decorators that make a DTO the wire contract.
Module Anatomy
How the interface, entity, and DTO sit together with the service and repository.
Add an Entity & Migration
The end-to-end handoff for adding a new table.