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