TypeORM Entity Standards
Entities are your database table definitions. They map TypeScript classes to PostgreSQL tables and define how data is stored, validated, and retrieved.
Core Principles
Use camelCase in code, snake_case in database
We use SnakeNamingStrategy which automatically converts camelCase properties to snake_case columns. Write createdAt in code, it becomes created_at in the database.
Every column needs a default OR is required
There's no middle ground. Either provide a sensible default value or make the field required (user must provide it).
Optional fields need three things
Use nullable: true, default: null, and type as Type | null. All three are required for proper null handling.
Let TypeORM catch errors
Don't use optional chaining (?.) or fallbacks in transformers. Let the type system and validators catch missing required values instead of masking bugs.
Add comments to describe fields Each property should have a comment above it explaining its purpose, especially for complex or domain-specific fields. Keep comments concise and focused on what the field stores.
Basic Structure
Every entity follows this pattern - UUID primary key, business columns, and timestamp tracking. The @Entity decorator takes the table name (always singular), and indexes are defined at the class level.
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index } from 'typeorm';
@Entity('user') // Table name: singular snake_case
@Index(['email'], { unique: true, where: '"deletedAt" IS NULL' })
export class UserEntity {
// Unique identifier for this user
@PrimaryGeneratedColumn('uuid')
public id: string;
// User's email address - required for authentication
@Column({ type: 'varchar', length: 255 })
public email: string;
// User's display name - optional with default empty string
@Column({ type: 'varchar', length: 255, default: '' })
public name: string;
// User's biography or description - optional and nullable
@Column({ type: 'text', nullable: true, default: null })
public bio: string | null;
@Column({ type: 'boolean', default: false })
public isVerified: boolean;
@CreateDateColumn()
public createdAt: Date;
@UpdateDateColumn()
public updatedAt: Date;
@DeleteDateColumn()
public deletedAt: Date | null; // For soft deletes
}
Common Column Patterns
These cover most use cases you'll encounter. The key difference is whether a field is required (no default) or optional (has a default value).
// Required field - user must provide this
@Column({ type: 'varchar', length: 255 })
public email: string;
// Optional with default - empty string if not provided
@Column({ type: 'varchar', length: 255, default: '' })
public name: string;
// Optional nullable - truly optional, null if not provided
@Column({ type: 'text', nullable: true, default: null })
public bio: string | null;
// Sensitive data - never returned in queries
@Column({ type: 'text', select: false })
public password: string;
// Normalized field - automatically cleaned before saving
@Column({
type: 'varchar',
length: 255,
transformer: {
to: (value: string) => value.toLowerCase().trim(),
from: (value: string) => value,
},
})
public email: string;
// JSON field - great for flexible data
@Column({ type: 'jsonb', default: {} })
public settings: Record<string, any>;
// Enum field - constrained to specific values
@Column({ type: 'enum', enum: UserRole, default: UserRole.USER })
public role: UserRole;
Lifecycle Hooks
Hooks let you run logic before/after database operations. Most common use case is hashing passwords. The guard clause prevents rehashing an already-hashed password.
import * as argon2 from 'argon2';
@BeforeInsert()
@BeforeUpdate()
async hashPassword() {
// Only hash if password exists and isn't already hashed
if (this.password && !this.password.startsWith('$argon2')) {
this.password = await argon2.hash(this.password);
}
}
Helper Methods
Add methods to your entity for common operations. Password verification and JSON serialization are the most common.
async verifyPassword(plainPassword: string): Promise<boolean> {
try {
return await argon2.verify(this.password, plainPassword);
} catch {
return false;
}
}
toJSON() {
// Remove sensitive fields when serializing
const { password, ...result } = this;
return result;
}
Soft Deletes
Soft deletes mark records as deleted without removing them from the database. Critical: unique indexes must include the where clause to allow reusing emails/usernames after soft delete.
Important: Raw SQL in the where clause uses actual database column names (snake_case), not property names (camelCase).
@DeleteDateColumn()
public deletedAt: Date | null;
// Allows same email to be used again after soft delete
// Note: where clause uses 'deleted_at' (DB column), not 'deletedAt' (property)
@Index(['email'], { unique: true, where: '"deleted_at" IS NULL' })
Relations
Define relationships between entities. Always include the foreign key column explicitly for many-to-one relationships.
// Many-to-One: A post belongs to one user
@ManyToOne(() => UserEntity, (user) => user.posts)
@JoinColumn({ name: 'user_id' }) // Explicit column name
public user: UserEntity;
@Column({ type: 'uuid' })
public userId: string; // Foreign key (becomes user_id in DB)
// One-to-Many: A user has many posts
@OneToMany(() => PostEntity, (post) => post.user)
public posts: PostEntity[];
Denormalizing Ancestor Ids
A child entity carries a foreign key column for every ancestor, not just its immediate parent (see the style guide's "Cascade Foreign Keys"). A comment that belongs to a post that belongs to a user gets both postId and userId columns, so a query for "all comments by this user" doesn't need a join through post.
@Entity('comment')
export class CommentEntity {
@PrimaryGeneratedColumn('uuid')
public id: string;
// Immediate parent
@Column({ type: 'uuid' })
public postId: string;
// Grandparent, denormalized from post.userId - not a real foreign key
// on this table's own relation, just a copy for filtering
@Column({ type: 'uuid' })
public userId: string;
@ManyToOne(() => PostEntity)
@JoinColumn({ name: 'post_id' })
public post: PostEntity;
}
The service that creates a CommentEntity fetches PostEntity anyway (to check the post exists / the caller can comment on it), so userId comes from that same fetch, not from a separately-trusted value on the request:
async createOne(values: CreateCommentInput): Promise<Comment> {
const post = await this.postService.readOne({ id: values.postId });
const entity = this.commentRepository.create({
postId: values.postId,
userId: post.userId, // from the parent, not values.userId
body: values.body,
});
return this.toDomain(await this.commentRepository.save(entity));
}
This is why a child only ever needs to look at its immediate parent to inherit the whole ancestor chain: post.userId is itself already denormalized from post's own parent, so comment never needs to reach past post to get it.
Batch writes look up the parent once, not once per row. If many rows share one parent (e.g. every item saved from a single sync job), fetch the parent before the loop:
// Good - one lookup for the whole batch
async saveMany(postId: string, items: Item[]): Promise<void> {
const post = await this.postService.readOne({ id: postId });
for (const item of items) {
await this.commentRepository.save(
this.commentRepository.create({ postId, userId: post.userId, ...item }),
);
}
}
// Bad - re-fetches the same parent on every iteration
async saveMany(postId: string, items: Item[]): Promise<void> {
for (const item of items) {
const post = await this.postService.readOne({ id: postId });
await this.commentRepository.save(
this.commentRepository.create({ postId, userId: post.userId, ...item }),
);
}
}
For a real multi-parent example (a join table denormalizing from two parents that share a common grandparent), see backend/src/v1/user-integration-embedding-config/user-integration-embedding-config.service.ts and backend/src/integration/_polling/polling.service.ts.
Anti-Patterns
Common mistakes to avoid. These will save you hours of debugging.
Don't use optional chaining for required fields Masks bugs by making required fields behave like optional ones.
// Bad - hides missing values
to: (value: string) => value?.toLowerCase() || ''
// Good - let TypeORM catch it
to: (value: string) => value.toLowerCase()
Don't forget soft delete filter on unique indexes
Without the where clause, you can't reuse the same email after soft delete. Remember: raw SQL uses database column names (snake_case).
// Bad - can't reuse email after delete
@Index(['email'], { unique: true })
// Good - email can be reused
// IMPORTANT: Use 'deleted_at' (DB column), not 'deletedAt' (property)
@Index(['email'], { unique: true, where: '"deleted_at" IS NULL' })
Don't rehash already hashed passwords Without the check, updates will hash an already-hashed password.
// Bad - hashes the hash on updates
async hashPassword() {
this.password = await argon2.hash(this.password);
}
// Good - only hashes plain text
async hashPassword() {
if (this.password && !this.password.startsWith('$argon2')) {
this.password = await argon2.hash(this.password);
}
}
Don't expose sensitive fields Two layers of protection: exclude from queries AND exclude from JSON.
// Layer 1: Never load from DB
@Column({ type: 'text', select: false })
public password: string;
// Layer 2: Never serialize
toJSON() {
const { password, apiKey, ...result } = this;
return result;
}
Other common mistakes:
- Using auto-increment IDs instead of UUID
- Using plural table names (
usersinstead ofuser) - Forgetting
publicmodifier on properties