Advanced TypeScript Patterns for Clean Architecture
Farukh Saifi
TypeScript is often treated as a simple linter on top of JavaScript. But when building large-scale applications, treating TypeScript as a first-class architectural tool prevents runtime bugs and enforces clean boundaries between your business logic and external infrastructure.By leveraging advanced type mechanics, you can design a codebase that is self-documenting, strictly validated, and highly adaptable.
1. Branded Types: Preventing Primitive Obsession
Primitive obsession occurs when we use generic types like string or number to represent domain concepts like user IDs, email addresses, or transaction amounts. This leads to accidental bugs, such as passing a ProductId into a function expecting a UserId.TypeScript's structural type system usually allows this because both are just strings. Branded types solve this by attaching a unique compiler-only tag to primitives.Now, the compiler acts as a strict gatekeeper:This pattern incurs zero runtime overhead. The helper types disappear entirely once compiled to JavaScript.
2. Mapped Types with Template Literals: Enforcing API Contracts
When building clean architectures, you often need to transform data structures as they cross boundaries. For example, converting a database model with snake_case columns into a domain model with camelCase properties.We can automate this mapping at the type level using mapped types and template literal types.Let's see this in action with a raw database payload:Using this pattern ensures that your domain layer remains decoupled from database naming conventions without requiring manual, error-prone type re-declarations.
3. Discriminated Unions with Exhaustiveness Checking
Clean architecture relies on explicit state handling. When handling domain events or state transitions, using discriminated unions ensures all possibilities are accounted for.To guarantee that future developers don't forget to handle new states, we can enforce exhaustiveness checking at compile time.By using the never type, the compiler will fail if a switch block does not handle every single union member:
4. Abstract Interfaces with Generics for Portability
In clean architecture, high-level business logic should not depend on low-level details like databases or HTTP clients. Instead, we define abstract interfaces (ports) that our adapters implement.Using generics and conditional types allows us to write highly reusable, type-safe repository interfaces.This abstraction allows you to swap out an in-memory test database for a production PostgreSQL adapter without altering a single line of your core business use cases.
// Define the brand helper
declare const brand: unique symbol;
export type Brand<T, TBrand extends string> = T & { readonly [brand]: TBrand };
// Define domain-specific types
export type UserId = Brand<string, "UserId">;
export type ProductId = Brand<string, "ProductId">;
// Type-safe constructors (Type Assertions)
export function makeUserId(id: string): UserId {
return id as UserId;
}
export function makeProductId(id: string): ProductId {
return id as ProductId;
}
Typescript
function getUserDetails(userId: UserId) {
// ...
}
const prodId = makeProductId("prod_123");
// Compiler Error: Argument of type 'ProductId' is not assignable to parameter of type 'UserId'.
getUserDetails(prodId);
Typescript
type CamelCase<S extends string> = S extends `${infer T}_${infer U}`
? `${Lowercase<T>}${Capitalize<CamelCase<U>>}`
: Lowercase<S>;
export type CamelizeKeys<T> = {
[K in keyof T as CamelCase<Extract<K, string>>]: T[K];
};
function handlePayment(state: PaymentState): string {
switch (state.type) {
case "pending":
return "Show spinner";
case "success":
return `Download receipt from ${state.receiptUrl}`;
case "failed":
return `Error: ${state.reason}`;
default:
// If a new state "refunded" is added to PaymentState,
// this line will trigger a TypeScript compilation error.
throw new UnreachableCaseError(state);
}
}
Typescript
export interface Repository<T> {
findById(id: string): Promise<T | null>;
save(entity: T): Promise<void>;
}
// We can extend this for specific entities while preserving strict types
export interface UserRepository extends Repository<DomainUser> {
findByEmail(email: string): Promise<DomainUser | null>;
}