Skip to content

DOMAIN:BACKEND:TYPESCRIPT_PATTERNS

OWNER: urszula (Team Alfa), maxim (Team Bravo)
UPDATED: 2026-03-24
SCOPE: advanced TypeScript patterns for GE backend development
VERSION: TypeScript 5.x, strict mode mandatory


TS:STRICT_MODE

RULE: all GE projects use strict mode — this is non-negotiable
RULE: strict: true enables: strictNullChecks, noImplicitAny, noImplicitThis, alwaysStrict, strictBindCallApply, strictFunctionTypes, strictPropertyInitialization, useUnknownInCatchVariables

ADDITIONAL_STRICT_FLAGS

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "forceConsistentCasingInFileNames": true,
    "verbatimModuleSyntax": true
  }
}

WHY noUncheckedIndexedAccess: array[0] returns T | undefined instead of T — catches real bugs
WHY verbatimModuleSyntax: enforces explicit import type — cleaner output, faster compilation


TS:DISCRIMINATED_UNIONS

FOR_API_RESPONSES

// The Result pattern — typed success/failure without exceptions
type Result<T, E = Error> =
  | { success: true; data: T }
  | { success: false; error: E };

// Usage in service layer
async function findUser(id: string): Promise<Result<User, 'NOT_FOUND' | 'DB_ERROR'>> {
  try {
    const user = await db.query.users.findFirst({ where: eq(users.id, id) });
    if (!user) return { success: false, error: 'NOT_FOUND' };
    return { success: true, data: user };
  } catch {
    return { success: false, error: 'DB_ERROR' };
  }
}

// Consumer MUST handle both cases — TypeScript enforces this
const result = await findUser(userId);
if (result.success) {
  // result.data is User — narrowed by discriminant
  console.log(result.data.name);
} else {
  // result.error is 'NOT_FOUND' | 'DB_ERROR' — narrowed
  switch (result.error) {
    case 'NOT_FOUND': return c.json({ error: 'User not found' }, 404);
    case 'DB_ERROR': return c.json({ error: 'Database error' }, 500);
  }
}

FOR_DOMAIN_EVENTS

type DomainEvent =
  | { type: 'user.created'; payload: { userId: string; email: string } }
  | { type: 'user.updated'; payload: { userId: string; changes: Partial<User> } }
  | { type: 'user.deleted'; payload: { userId: string; reason: string } }
  | { type: 'project.created'; payload: { projectId: string; ownerId: string } };

function handleEvent(event: DomainEvent) {
  switch (event.type) {
    case 'user.created':
      // event.payload is { userId: string; email: string }
      sendWelcomeEmail(event.payload.email);
      break;
    case 'user.deleted':
      // event.payload is { userId: string; reason: string }
      auditDeletion(event.payload.userId, event.payload.reason);
      break;
    // TypeScript warns if you miss a case
  }
}

EXHAUSTIVENESS_CHECK

// Utility function to ensure all union cases are handled
function assertNever(value: never): never {
  throw new Error(`Unhandled discriminated union member: ${JSON.stringify(value)}`);
}

function handleStatus(status: 'active' | 'inactive' | 'suspended'): string {
  switch (status) {
    case 'active': return 'User is active';
    case 'inactive': return 'User is inactive';
    case 'suspended': return 'User is suspended';
    default: return assertNever(status); // Compile error if a case is missing
  }
}

RULE: use discriminated unions for any value that can be one of multiple shapes
RULE: always add exhaustiveness check with assertNever in switch statements
ANTI_PATTERN: using type | null without discriminant — forces null checks everywhere
FIX: use discriminated union with explicit success/failure variant


TS:BRANDED_TYPES

PURPOSE: prevent mixing up primitive types that represent different concepts
EXAMPLE: UserId and ProjectId are both strings, but must never be accidentally swapped

// Brand utility type
declare const __brand: unique symbol;
type Brand<T, B extends string> = T & { [__brand]: B };

// Define branded types
type UserId = Brand<string, 'UserId'>;
type ProjectId = Brand<string, 'ProjectId'>;
type Email = Brand<string, 'Email'>;
type Money = Brand<number, 'Money'>;

// Constructor functions with validation
function UserId(value: string): UserId {
  if (!value.match(/^[0-9a-f-]{36}$/)) throw new Error('Invalid UUID for UserId');
  return value as UserId;
}

function Email(value: string): Email {
  if (!value.includes('@')) throw new Error('Invalid email');
  return value as Email;
}

function Money(value: number): Money {
  if (value < 0) throw new Error('Money cannot be negative');
  return Math.round(value * 100) / 100 as Money;
}

// Usage — compiler prevents accidental mixing
function assignToProject(userId: UserId, projectId: ProjectId): void { /* ... */ }

const uid = UserId('550e8400-e29b-41d4-a716-446655440000');
const pid = ProjectId('660e8400-e29b-41d4-a716-446655440000');

assignToProject(uid, pid);   // OK
assignToProject(pid, uid);   // COMPILE ERROR — ProjectId is not assignable to UserId
assignToProject('raw-string', pid);  // COMPILE ERROR — string is not UserId

RULE: use branded types for IDs, emails, money amounts, and other domain primitives
RULE: validation happens in the constructor function — once validated, the type is trusted
ANTI_PATTERN: using raw string for all IDs — easy to mix up userId and projectId
FIX: brand each ID type separately


TS:ZOD_SCHEMA_INFERENCE

SINGLE_SOURCE_OF_TRUTH

// Define Zod schema ONCE — infer TypeScript type FROM it
import { z } from 'zod';

export const createUserSchema = z.object({
  email: z.string().email().toLowerCase(),
  name: z.string().min(1).max(200),
  role: z.enum(['admin', 'user', 'viewer']).default('user'),
  metadata: z.record(z.string(), z.unknown()).optional(),
});

// Infer type — never define the interface separately
export type CreateUserInput = z.infer<typeof createUserSchema>;
// Result: { email: string; name: string; role: "admin" | "user" | "viewer"; metadata?: Record<string, unknown> }

// For partial updates
export const updateUserSchema = createUserSchema.partial().omit({ email: true });
export type UpdateUserInput = z.infer<typeof updateUserSchema>;

RULE: define Zod schema first, infer TypeScript type from it — never the reverse
RULE: Zod schemas are the single source of truth for request shapes
WHY: eliminates drift between runtime validation and compile-time types

ZOD_TRANSFORMS

// Transform and validate in one step
const envSchema = z.object({
  PORT: z.coerce.number().int().min(1).max(65535).default(3000),
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url(),
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
});

// Parse at startup — crash early if env is invalid
export const env = envSchema.parse(process.env);
// env.PORT is number, env.NODE_ENV is narrowed enum — fully typed

ZOD_WITH_DRIZZLE_TYPES

import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
import { users } from './schema';

// Auto-generate Zod schema from Drizzle table
const insertUserSchema = createInsertSchema(users, {
  // Override specific fields for validation
  email: z.string().email().toLowerCase(),
  name: z.string().min(1).max(200),
});

const selectUserSchema = createSelectSchema(users);

TS:ERROR_HANDLING_PATTERNS

RESULT_TYPE (PREFERRED FOR BUSINESS LOGIC)

// lib/result.ts
export type Result<T, E = string> =
  | { ok: true; value: T }
  | { ok: false; error: E };

export const Ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });

// Usage in service
import { Result, Ok, Err } from '../lib/result';

async function transferFunds(
  from: UserId,
  to: UserId,
  amount: Money,
): Promise<Result<Transfer, 'INSUFFICIENT_FUNDS' | 'ACCOUNT_NOT_FOUND' | 'SAME_ACCOUNT'>> {
  if (from === to) return Err('SAME_ACCOUNT');

  const fromAccount = await db.query.accounts.findFirst({ where: eq(accounts.userId, from) });
  if (!fromAccount) return Err('ACCOUNT_NOT_FOUND');
  if (fromAccount.balance < amount) return Err('INSUFFICIENT_FUNDS');

  const transfer = await db.transaction(async (tx) => {
    // ... transfer logic
    return transferRecord;
  });

  return Ok(transfer);
}

// In route handler — map Result to HTTP response
app.post('/transfers', async (c) => {
  const result = await transferFunds(fromId, toId, amount);
  if (!result.ok) {
    const statusMap = {
      INSUFFICIENT_FUNDS: 422,
      ACCOUNT_NOT_FOUND: 404,
      SAME_ACCOUNT: 400,
    } as const;
    return c.json({
      success: false,
      error: { code: result.error, message: result.error.replace(/_/g, ' ').toLowerCase() },
    }, statusMap[result.error]);
  }
  return c.json({ success: true, data: result.value }, 201);
});

WHEN_TO_USE_EXCEPTIONS

RULE: use Result type for expected business failures (validation, not found, insufficient funds)
RULE: use exceptions for unexpected system failures (DB connection lost, OOM)
RULE: never use try/catch for control flow in business logic

DECISION_MATRIX:
- Expected error, caller must handle → Result<T, E>
- Unexpected error, should crash/log → throw Error (caught by global handler)
- Validation error → Zod parse (throws ZodError, caught by error middleware)

TYPED_ERROR_CLASSES

// For infrastructure errors that need stack traces
export class AppError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly statusCode: number,
    public readonly details?: unknown,
  ) {
    super(message);
    this.name = 'AppError';
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string, id: string) {
    super(`${resource} not found: ${id}`, 'NOT_FOUND', 404);
  }
}

export class ConflictError extends AppError {
  constructor(message: string) {
    super(message, 'CONFLICT', 409);
  }
}

TS:UTILITY_TYPES

COMMONLY_USED

// Make specific fields required
type WithRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;

// Make specific fields optional
type WithOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

// Deep readonly
type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};

// Extract union member by discriminant
type ExtractByType<T, V> = T extends { type: V } ? T : never;

// Ensure object has at least one key
type AtLeastOne<T, U = { [K in keyof T]: Pick<T, K> }> = Partial<T> & U[keyof U];

// Type-safe Object.keys
function typedKeys<T extends object>(obj: T): Array<keyof T> {
  return Object.keys(obj) as Array<keyof T>;
}

// Type-safe Object.entries
function typedEntries<T extends Record<string, unknown>>(obj: T): Array<[keyof T, T[keyof T]]> {
  return Object.entries(obj) as Array<[keyof T, T[keyof T]]>;
}

MAPPED_TYPES_FOR_API_ROUTES

// Define route map — type-safe route-to-handler mapping
type RouteMap = {
  'GET /users': { response: User[]; query: { page: number; limit: number } };
  'GET /users/:id': { response: User; params: { id: string } };
  'POST /users': { response: User; body: CreateUserInput };
  'PUT /users/:id': { response: User; params: { id: string }; body: UpdateUserInput };
};

// Extract types for a specific route
type RouteResponse<K extends keyof RouteMap> = RouteMap[K]['response'];
type RouteBody<K extends keyof RouteMap> = K extends keyof RouteMap
  ? 'body' extends keyof RouteMap[K] ? RouteMap[K]['body'] : never
  : never;

TS:CONST_ASSERTIONS_AND_ENUMS

RULE: prefer as const objects over TypeScript enums
WHY: enums generate runtime code, as const is zero-cost at runtime

// AVOID: TypeScript enum
enum Status {
  Active = 'active',
  Inactive = 'inactive',
}

// PREFER: const object
const Status = {
  Active: 'active',
  Inactive: 'inactive',
  Suspended: 'suspended',
} as const;

type Status = typeof Status[keyof typeof Status];
// Result: 'active' | 'inactive' | 'suspended'

// Even better: Zod enum (validates at runtime too)
const statusSchema = z.enum(['active', 'inactive', 'suspended']);
type Status = z.infer<typeof statusSchema>;

TS:GENERIC_PATTERNS

SERVICE_LAYER_GENERICS

// Generic CRUD service base
interface CrudService<T, CreateInput, UpdateInput> {
  findById(id: string): Promise<Result<T, 'NOT_FOUND'>>;
  findMany(opts: PaginationOpts): Promise<PaginatedResult<T>>;
  create(input: CreateInput): Promise<Result<T, 'VALIDATION_ERROR' | 'CONFLICT'>>;
  update(id: string, input: UpdateInput): Promise<Result<T, 'NOT_FOUND' | 'VALIDATION_ERROR'>>;
  delete(id: string): Promise<Result<void, 'NOT_FOUND'>>;
}

// Implementation
class UserService implements CrudService<User, CreateUserInput, UpdateUserInput> {
  async findById(id: string): Promise<Result<User, 'NOT_FOUND'>> {
    const user = await db.query.users.findFirst({ where: eq(users.id, id) });
    return user ? Ok(user) : Err('NOT_FOUND');
  }
  // ... other methods
}

GENERIC_REPOSITORY

// Type-safe repository pattern with Drizzle
import { PgTable } from 'drizzle-orm/pg-core';
import { InferSelectModel, InferInsertModel, eq } from 'drizzle-orm';

function createRepository<T extends PgTable>(table: T) {
  type Select = InferSelectModel<T>;
  type Insert = InferInsertModel<T>;

  return {
    async findById(id: string): Promise<Select | undefined> {
      const [result] = await db.select().from(table).where(eq((table as any).id, id));
      return result as Select | undefined;
    },
    async create(values: Insert): Promise<Select> {
      const [result] = await db.insert(table).values(values as any).returning();
      return result as Select;
    },
  };
}

// Usage
const userRepo = createRepository(users);
const user = await userRepo.findById('some-uuid');  // typed as User | undefined

TS:ASYNC_PATTERNS

TYPED_ASYNC_HELPERS

// Type-safe Promise.allSettled processing
async function settledResults<T>(
  promises: Promise<T>[],
): Promise<{ fulfilled: T[]; rejected: Error[] }> {
  const results = await Promise.allSettled(promises);
  return {
    fulfilled: results
      .filter((r): r is PromiseFulfilledResult<T> => r.status === 'fulfilled')
      .map((r) => r.value),
    rejected: results
      .filter((r): r is PromiseRejectedResult => r.status === 'rejected')
      .map((r) => r.reason instanceof Error ? r.reason : new Error(String(r.reason))),
  };
}

// Retry with exponential backoff
async function withRetry<T>(
  fn: () => Promise<T>,
  opts: { maxRetries: number; baseDelayMs: number } = { maxRetries: 3, baseDelayMs: 100 },
): Promise<T> {
  let lastError: Error | undefined;
  for (let i = 0; i <= opts.maxRetries; i++) {
    try {
      return await fn();
    } catch (err) {
      lastError = err instanceof Error ? err : new Error(String(err));
      if (i < opts.maxRetries) {
        await new Promise((r) => setTimeout(r, opts.baseDelayMs * Math.pow(2, i)));
      }
    }
  }
  throw lastError;
}

TS:TYPE_GUARDS

// Custom type guard for narrowing
function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    'email' in value &&
    typeof (value as User).email === 'string'
  );
}

// Assertion function — throws if invalid
function assertUser(value: unknown): asserts value is User {
  if (!isUser(value)) {
    throw new Error('Expected User object');
  }
}

// With Zod (preferred)
const userSchema = z.object({ id: z.string(), email: z.string().email() });
function isUser(value: unknown): value is z.infer<typeof userSchema> {
  return userSchema.safeParse(value).success;
}

TS:ANTI_PATTERNS

ANTI_PATTERN: using any anywhere
FIX: use unknown and narrow with type guards or Zod

ANTI_PATTERN: using as type assertion to silence errors
FIX: fix the actual type — assertions hide bugs

ANTI_PATTERN: defining TypeScript interface AND Zod schema separately for same shape
FIX: define Zod schema, infer type with z.infer<typeof schema>

ANTI_PATTERN: using ! non-null assertion operator
FIX: handle the null/undefined case explicitly

ANTI_PATTERN: using // @ts-ignore or // @ts-expect-error without comment
FIX: fix the type error; if impossible, use @ts-expect-error with explanation

ANTI_PATTERN: enum with numeric values (easy to accidentally compare with wrong number)
FIX: use string enums or const objects

ANTI_PATTERN: Object, Function, String as types (boxed types)
FIX: use object, (...args: any[]) => any, string (primitive types)


TS:AGENTIC_CHECKLIST

ON_WRITING_TYPESCRIPT:
1. CHECK: is strict: true enabled in tsconfig?
2. CHECK: are all function parameters and return types typed? (inference is fine for return)
3. CHECK: are Zod schemas the single source of truth for request/response shapes?
4. CHECK: are discriminated unions used for success/failure instead of nullable returns?
5. CHECK: are branded types used for domain primitives (IDs, emails)?
6. CHECK: are type guards used instead of as assertions?
7. CHECK: is any absent from the codebase? (search for it)
8. CHECK: are const objects used instead of enums?
9. RUN: npx tsc --noEmit — zero errors
10. RUN: vitest — zero failures