Deep dive into TypeScript: Expertise, implementation, and best practices
How domain-first typing, discriminated unions, and ergonomic generics turn sprawling codebases into predictable systems.
The promise of TypeScript is rarely about catching typos. It is about architectural clarity. When a codebase crosses the threshold of fifty thousand lines, the compiler stops being a convenience and becomes the primary contract between teams. I have watched engineering organizations treat types as an afterthought—slapping any over API responses, leaning on interface inheritance like it is Java circa 2008, and wondering why refactors still consume entire sprints. The reality is simpler: type safety scales only when it mirrors domain reality.
Modern product engineering demands a shift from runtime guessing to compile-time certainty. You do not write types to satisfy a linter. You write them to document business rules, enforce data contracts, and make invalid states unrepresentable. The following breakdown distills years of production TypeScript into actionable patterns. It covers domain-first design, scalable generics, union narrowing, and TSX ergonomics. If your team is still debating type versus interface, you are missing the point. The real work begins when types drive architecture.
The Architecture of Type-Safety
Most teams start with models. They define User, Order, Payment. That is a mistake. Models without constraints are just dictionaries with extra steps. Domain-first typing flips the script: you define states, transitions, and invariants before you define data structures. A type should answer what can exist, not just what might be returned from a database.
I design domain-first types by isolating three layers: shape (raw data), constraint (business rules), and behavior (valid operations). This separation prevents type leakage. When an Order moves from pending to fulfilled, the type system should physically prevent calling cancel() on the fulfilled variant. You achieve this through discriminated unions and branded types, not runtime flags buried in service layers.
Before / After: Model vs Domain-First Typing
// BEFORE: Leaky, runtime-dependent
interface Order {
id: string;
status: string; // 'pending' | 'paid' | 'cancelled' | 'fulfilled'
amount: number;
}
// AFTER: Domain-constrained, compile-time enforced
type OrderStatus = 'pending' | 'paid' | 'fulfilled' | 'cancelled';
type Order = {
id: string;
status: OrderStatus;
amount: number;
createdAt: Date;
} & (Order.status extends 'paid' | 'fulfilled'
? { paymentId: string }
: { paymentId?: never });
The after pattern ties optional fields to explicit state branches. The compiler rejects invalid property combinations before the code ships.
Types are not annotations. They are executable specifications. When your domain logic lives in the type layer, your runtime code shrinks to pure orchestration.
Guard functions are the bridge between external data and internal certainty. Never trust network payloads, environment variables, or third-party SDKs. Instead of as assertions, build narrowing guards that return data is ValidOrder. This shifts validation from a scattered concern to a centralized, testable contract. The compiler then tracks the narrowed shape through every downstream function.
Generics That Scale, Not Bloat
Generics are often misused as a catch-all for flexibility. The result is T everywhere, zero readability, and a developer experience that feels like solving a puzzle. Generics should only exist when they enforce a relationship between inputs and outputs. If your function signature reads function process<T>(data: T): T and you do not use T to constrain behavior, you are just renaming variables.
I structure generic API clients around constraints, not freedom. By binding T to a base contract (like extends BaseResponse), you unlock autocomplete for shared fields while preserving variant-specific properties. This pattern scales beautifully across SDKs, GraphQL clients, and internal service wrappers.
- Constrain early: Use
extendsto lock down shared properties. Avoid unconstrainedT. - Infer, don't repeat: Let
inferextract return types from wrapped functions. Write the implementation once, let the compiler read the signature. - Conditional branching: Use
T extends X ? Y : Zfor variant behavior. Keep branches exhaustive. - Default to strict: Provide meaningful defaults, but require explicit overrides for edge cases.
- Document boundaries: Use JSDoc on generic parameters to explain intent, not syntax.
Consider a typed fetch wrapper. Instead of returning Promise<any> or forcing manual casting, you define the request schema, the success shape, and the error union. The compiler tracks which endpoints return paginated data, which return singletons, and which throw validation errors. This eliminates entire categories of undefined checks at runtime.
When generics feel complex, it is usually because the domain is underspecified. Tighten the constraints, and the type system will simplify automatically.
Narrowing Reality with Discriminated Unions
Discriminated unions are the single most powerful pattern for state management in TypeScript. They replace boolean flags, enum sprawl, and defensive if (obj.type === 'x') blocks with compile-time exhaustiveness. The principle is straightforward: every variant shares a literal discriminator, and every operation handles all variants explicitly.
I implement this through a state-first workflow. Define the union. Write a switch or pattern-matching utility. Use never to catch missing branches. When a new variant is added, the compiler points directly to every location that requires updating. No more silent bugs in production.
SVG Flow: Discriminated Union Narrowing
A single discriminator routes execution paths. The compiler guarantees no variant escapes the handler.
Guards complement unions by validating raw input. A well-written isOrderEvent function checks for the discriminator, validates the payload shape, and returns a type predicate. This creates a trusted boundary between the network and your application core. Everything downstream operates on narrowed types, eliminating defensive programming.
TSX Ergonomics for Product Engineers
React components are often typed like data models. They should not be. Component types are interaction contracts. Props define what the parent can pass, events define what the child can emit, and state defines what the UI can render. I structure TSX ergonomics around three rules: prop inference over explicit annotation, variant-driven styling, and strict children boundaries.
Instead of manually typing every prop, use ComponentPropsWithoutRef for wrappers. Instead of spreading booleans like isPrimary and isSecondary, use a variant union. This prevents mutually exclusive props and enables exhaustive style mapping. When you combine this with forwardRef and strict generic props, your component library becomes self-documenting.
Step-by-Step: Ergonomic Component Typing
- Define variants:
type Variant = 'default' | 'primary' | 'ghost' - Bind props:
interface ButtonProps<V extends Variant> { variant: V; onClick?: (e: React.MouseEvent) => void; } - Infer styles: Map variant to CSS tokens. Use
constassertions for compile-time safety. - Enforce children: Use
ReactNodeonly when composition is intended. Otherwise, usenever. - Export generics: Let consumers narrow types via
<Button<'primary'> />without manual casting.
A five-step pipeline that replaces prop sprawl with predictable, type-driven rendering.
| Anti-Pattern | Production Pattern |
|---|---|
isActive?: boolean | state: 'active' | 'idle' | 'loading' |
children: ReactNode | children: (props: InferProps) => ReactNode |
as: keyof JSX.IntrinsicElements | Polymorphic<T> with strict element constraints |
When TSX typing aligns with user intent, refactoring becomes trivial. You change a variant, the compiler highlights every broken style mapping, every missing handler, and every invalid child composition. This is not theoretical. It is how high-velocity teams ship UI without regression anxiety.
Production Guardrails
Scaling TypeScript requires discipline, not just syntax. I enforce strict mode, ban any, and require explicit return types for public APIs. I also treat tsconfig as a living document. As your codebase matures, tighten strictNullChecks, enable noUncheckedIndexedAccess, and adopt verbatimModuleSyntax for cleaner tree-shaking.
Partial<T> or Record<string, unknown>. They erase intent. When a type becomes too flexible, it stops protecting you.
The best TypeScript code looks almost like JavaScript. It uses minimal annotations, relies on inference, and reserves complex types for system boundaries. If you find yourself writing elaborate conditional types for everyday logic, step back. The problem is usually architectural, not syntactic.
For teams transitioning from loosely typed ecosystems, the friction is real. But once domain contracts are established, velocity compounds. Refactors become compiler-guided migrations. Onboarding becomes reading type signatures. Debugging becomes tracing invalid state transitions. This is the difference between writing TypeScript and engineering with it.
I help teams build production systems with TypeScript. Explore my portfolio or get in touch for consulting.
Frequently Asked Questions
Should I use type or interface?
Use interface for public APIs and object shapes that benefit from declaration merging or extension. Use type for unions, intersections, tuples, and mapped types. In modern TypeScript, the compiler treats them identically for simple objects. Choose based on intent, not dogma.
How do I handle third-party libraries with poor typing?
Do not patch @types globally. Create a local .d.ts module augmentation or write a thin typed adapter. Wrap the library in your own interface, validate inputs at the boundary, and export only the narrowed surface. This contains type pollution and preserves your core contracts.
Is strict mode worth the initial migration cost?
Absolutely. Strict mode is not a compiler flag; it is a quality baseline. The migration cost is paid once, usually in a focused sprint. The compounding return is fewer runtime errors, faster refactors, and self-documenting codebases. Teams that skip strict mode inevitably pay the debt later with fragmented guard logic and defensive as any assertions.