Skip to main content

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[];

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 (users instead of user)
  • ❌ Forgetting public modifier on properties