TypeScript Is More Than Type Annotations
Most teams use TypeScript for basic type annotations — string, number, interface. But TypeScript's type system is a full-blown programming language capable of expressing constraints that eliminate entire categories of runtime bugs. The patterns in this article move beyond "type the props" into territory where the type system actively prevents impossible states.
These aren't academic exercises. Every pattern here solves a real problem I've encountered repeatedly in production React and Next.js applications.
#1 Discriminated Unions: Eliminating Impossible States
The most impactful TypeScript pattern for UI state management. Instead of a bag of optional properties, you model each state variant explicitly — making it impossible to have a 'loading' state with 'data' or an 'error' state without a message.
Model State Machines with Union Types
// ❌ Loose type: allows impossible combinations
interface AsyncState<T> {
isLoading: boolean;
data?: T;
error?: string;
}
// Nothing prevents: { isLoading: true, data: [...], error: "oops" }
// ✅ Discriminated union: each state is explicit
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
// TypeScript enforces correctness at every usage site
function renderState<T>(state: AsyncState<T>) {
switch (state.status) {
case 'idle':
return <Placeholder />;
case 'loading':
return <Spinner />;
case 'success':
// TypeScript knows 'data' exists here
return <DataView data={state.data} />;
case 'error':
// TypeScript knows 'error' exists here
return <ErrorBanner message={state.error} />;
}
}The status field acts as a discriminant. TypeScript narrows the type in each branch automatically — no type assertions needed.
- Impossible states become compile-time errors
- Exhaustive switch checks catch missing handlers
- IDE autocompletion shows only valid properties per state
#2 Branded Types: Preventing Value Confusion
TypeScript's structural typing means any string is assignable to any other string type alias. A userId and an orderId are both strings — but accidentally swapping them causes real bugs. Branded types add a phantom property to create nominally-typed values.
Create Branded Primitives
// Brand creator utility
type Brand<T, B extends string> = T & { readonly __brand: B };
// Define branded types
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type Email = Brand<string, 'Email'>;
// Smart constructors with runtime validation
function createUserId(id: string): UserId {
if (!id.startsWith('usr_')) throw new Error('Invalid user ID');
return id as UserId;
}
function createEmail(value: string): Email {
if (!/^[^@]+@[^@]+\.[^@]+$/.test(value))
throw new Error('Invalid email');
return value as Email;
}
// Now TypeScript prevents mix-ups
function getOrder(userId: UserId, orderId: OrderId) { ... }
const uid = createUserId('usr_abc123');
const oid = 'ord_xyz789' as OrderId;
getOrder(uid, oid); // ✅ Correct
getOrder(oid, uid); // ❌ Compile error!- Function argument swaps caught at compile time
- Domain invariants enforced through smart constructors
- Self-documenting code: types carry semantic meaning
#3 Utility Types for Component APIs
React component props can become complex. TypeScript's built-in utility types and custom mapped types let you derive prop types from data, enforce constraints, and build composable component APIs.
Derive Props from Data
// Derive column config from the data shape
type ColumnConfig<T> = {
[K in keyof T]: {
key: K;
header: string;
render?: (value: T[K], row: T) => React.ReactNode;
sortable?: boolean;
};
}[keyof T];
interface DataTableProps<T> {
data: T[];
columns: ColumnConfig<T>[];
onRowClick?: (row: T) => void;
}
// Usage: TypeScript knows exactly which keys and value types are valid
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
<DataTable<User>
data={users}
columns={[
{ key: 'name', header: 'Name' },
{ key: 'role', header: 'Role', render: (role) => (
// TypeScript knows 'role' is 'admin' | 'user'
<Badge variant={role === 'admin' ? 'default' : 'secondary'}>
{role}
</Badge>
)},
]}
/>Polymorphic Components with 'as' Props
import type { ComponentPropsWithoutRef, ElementType } from 'react';
type BoxProps<T extends ElementType> = {
as?: T;
children: React.ReactNode;
} & ComponentPropsWithoutRef<T>;
function Box<T extends ElementType = 'div'>({
as,
children,
...props
}: BoxProps<T>) {
const Component = as || 'div';
return <Component {...props}>{children}</Component>;
}
// Usage: props are type-safe based on the 'as' element
<Box as="a" href="/about">Link</Box> // ✅ href is valid
<Box as="button" onClick={handle}>Click</Box> // ✅ onClick is valid
<Box as="a" onClick={handle}>Bad</Box> // ⚠️ works but suspicious- Component APIs are self-documenting and impossible to misuse
- Column configurations are validated against the actual data shape
- Polymorphic components carry correct HTML attributes per element
#4 Type-Safe API Layers
API calls are a boundary where type safety often breaks down. You fetch data as 'any' and cast it, losing all guarantees. A type-safe API layer with runtime validation ensures your types match reality.
Zod Schemas as Single Source of Truth
import { z } from 'zod';
// Schema is both the runtime validator AND the type
const UserSchema = z.object({
id: z.string(),
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['admin', 'user']),
createdAt: z.string().datetime(),
});
// Derive the TypeScript type from the schema
type User = z.infer<typeof UserSchema>;
// Type-safe fetch that validates at runtime
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error('Failed to fetch user');
const data = await res.json();
return UserSchema.parse(data);
// If the API response doesn't match, you get a
// clear error instead of undefined behavior downstream
}- Runtime validation catches API contract drift immediately
- Types are derived from schemas, never duplicated
- Invalid data throws at the boundary, not deep in component trees
Key Takeaways
- 1Discriminated unions model state machines that eliminate impossible states at compile time
- 2Branded types prevent value confusion (userId vs orderId) without runtime overhead in most cases
- 3Derive component props from data types — never let prop definitions drift from the actual data
- 4Zod schemas unify runtime validation and TypeScript types into a single source of truth
- 5The goal isn't more types — it's better types that make wrong code unrepresentable