There’s a moment in every developer’s career when they realize the best bugs are the ones that never make it to production. Better yet are the bugs that never make it past your code editor. TypeScript gives us a powerful tool to achieve this: making invalid states unrepresentable.
This isn’t about catching typos or getting autocomplete (though those are nice). It’s about encoding your business logic directly into the type system so that entire categories of bugs become compile-time errors instead of runtime disasters.
The Philosophy: Make Wrong Code Look Wrong
Consider this common JavaScript pattern:
function processPayment(amount: number, status: string) {
if (status === "pending") {
// charge the payment
} else if (status === "completed") {
// refund the payment
}
}
processPayment(100, "pendingg"); // Typo - runtime error
processPayment(100, "cancelled"); // What should happen here?
This code compiles fine, but it’s a minefield. Typos pass unnoticed. Invalid states are possible. The relationship between status and the operations isn’t encoded anywhere.
Now consider this:
type PendingPayment = { status: "pending"; amount: number };
type CompletedPayment = { status: "completed"; amount: number; transactionId: string };
type Payment = PendingPayment | CompletedPayment;
function chargePayment(payment: PendingPayment) { /* ... */ }
function refundPayment(payment: CompletedPayment) { /* ... */ }
// This won't compile - type error!
chargePayment({ status: "completed", amount: 100, transactionId: "123" });
We’ve made it impossible to charge a completed payment. The compiler enforces our business rules.
Pattern 1: Discriminated Unions (Tagged Unions)
Discriminated unions are the foundation of type-safe state machines. They ensure that certain combinations of data are impossible.
Example: API Request States
The classic mistake:
interface ApiState {
loading: boolean;
data: UserData | null;
error: Error | null;
}
// These invalid states are all possible:
const invalid1: ApiState = { loading: true, data: userData, error: null };
const invalid2: ApiState = { loading: false, data: null, error: null };
const invalid3: ApiState = { loading: false, data: userData, error: someError };
The type-safe approach:
type ApiState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function renderUser(state: ApiState<UserData>) {
switch (state.status) {
case "idle":
return <div>Click to load</div>;
case "loading":
return <div>Loading...</div>;
case "success":
// TypeScript knows `state.data` exists here
return <div>{state.data.name}</div>;
case "error":
// TypeScript knows `state.error` exists here
return <div>Error: {state.error.message}</div>;
}
}
Now invalid states are literally unrepresentable. You cannot have loading: true with data present. TypeScript ensures exhaustive case handling and knows exactly what properties exist in each branch.
Example: Form Validation
type FormField<T> =
| { status: "pristine" }
| { status: "validating" }
| { status: "valid"; value: T }
| { status: "invalid"; errors: string[] };
type LoginForm = {
email: FormField<string>;
password: FormField<string>;
};
function canSubmit(form: LoginForm): boolean {
return (
form.email.status === "valid" &&
form.password.status === "valid"
);
}
function submit(form: LoginForm) {
if (form.email.status === "valid" && form.password.status === "valid") {
// TypeScript knows form.email.value and form.password.value exist
api.login(form.email.value, form.password.value);
}
}
Pattern 2: Branded Types (Nominal Typing)
TypeScript uses structural typing, which means two types with the same structure are considered identical. Sometimes you want to distinguish between semantically different values even if they’re structurally the same.
type UserId = string;
type ProductId = string;
function getUser(id: UserId) { /* ... */ }
function getProduct(id: ProductId) { /* ... */ }
const userId: UserId = "user_123";
const productId: ProductId = "prod_456";
// This compiles but is wrong!
getUser(productId);
Enter branded types:
type Brand<K, T> = K & { __brand: T };
type UserId = Brand<string, "UserId">;
type ProductId = Brand<string, "ProductId">;
function makeUserId(id: string): UserId {
return id as UserId;
}
function makeProductId(id: string): ProductId {
return id as ProductId;
}
function getUser(id: UserId) { /* ... */ }
function getProduct(id: ProductId) { /* ... */ }
const userId = makeUserId("user_123");
const productId = makeProductId("prod_456");
// Type error! Cannot pass ProductId where UserId is expected
getUser(productId);
Real-World Use Cases for Branded Types
Validated Email Addresses:
type EmailAddress = Brand<string, "EmailAddress">;
function validateEmail(input: string): EmailAddress | null {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(input) ? (input as EmailAddress) : null;
}
function sendEmail(to: EmailAddress, subject: string) {
// We know `to` is a valid email
}
const userInput = "notanemail";
// Type error - can't pass raw string
sendEmail(userInput, "Hello");
const validated = validateEmail(userInput);
if (validated) {
// This works
sendEmail(validated, "Hello");
}
Sanitized HTML:
type SanitizedHTML = Brand<string, "SanitizedHTML">;
function sanitizeHTML(input: string): SanitizedHTML {
// Actual sanitization logic
return sanitized as SanitizedHTML;
}
function renderHTML(html: SanitizedHTML) {
document.innerHTML = html;
}
const userInput = "";
// Type error - prevents XSS
renderHTML(userInput);
// This works
renderHTML(sanitizeHTML(userInput));
Positive Numbers:
type PositiveNumber = Brand<number, "PositiveNumber">;
function makePositive(n: number): PositiveNumber | null {
return n > 0 ? (n as PositiveNumber) : null;
}
function calculateInterest(principal: PositiveNumber, rate: number) {
// No need to check if principal > 0
return principal * rate;
}
Pattern 3: Phantom Types
Phantom types let you track information at the type level without runtime overhead.
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type Request<Method extends HttpMethod> = {
method: Method;
url: string;
body?: Method extends "GET" ? never : unknown;
};
// This works
const postReq: Request<"POST"> = {
method: "POST",
url: "/api/users",
body: { name: "John" }
};
// Type error! GET requests cannot have a body
const getReq: Request<"GET"> = {
method: "GET",
url: "/api/users",
body: { name: "John" }
};
Example: Type-Safe Builder Pattern
type Builder<Built extends boolean> = {
_built: Built;
name?: string;
age?: number;
};
type BuilderMethods<Built extends boolean> = {
setName: (name: string) => BuilderMethods<Built>;
setAge: (age: number) => BuilderMethods<Built>;
build: Built extends false ? () => BuilderMethods<true> & { result: User } : never;
};
type User = { name: string; age: number };
function createBuilder(): BuilderMethods<false> {
let name: string | undefined;
let age: number | undefined;
return {
setName(n: string) {
name = n;
return this;
},
setAge(a: number) {
age = a;
return this;
},
build() {
if (!name || !age) throw new Error("Incomplete");
return {
...this,
result: { name, age }
} as any;
}
};
}
const builder = createBuilder()
.setName("John")
.setAge(30)
.build();
// Type error! Can't build twice
builder.build();
// TypeScript knows result exists
console.log(builder.result.name);
Pattern 4: Const Assertions and Literal Types
Const assertions create exact types instead of widened types, enabling precise type checking.
// Without const assertion
const config = {
apiUrl: "https://api.example.com",
timeout: 5000
};
// Type: { apiUrl: string; timeout: number }
// With const assertion
const config = {
apiUrl: "https://api.example.com",
timeout: 5000
} as const;
// Type: { readonly apiUrl: "https://api.example.com"; readonly timeout: 5000 }
Example: Type-Safe Configuration
const ENVIRONMENTS = ["development", "staging", "production"] as const;
type Environment = typeof ENVIRONMENTS[number];
const config = {
development: { apiUrl: "http://localhost:3000", debug: true },
staging: { apiUrl: "https://staging.api.com", debug: true },
production: { apiUrl: "https://api.com", debug: false }
} as const;
function getConfig(env: Environment) {
return config[env];
}
// Type error!
getConfig("test");
// This works, and TypeScript knows the exact shape
const prodConfig = getConfig("production");
// prodConfig.debug is type `false`, not `boolean`
Example: Type-Safe Event System
const EVENTS = {
USER_LOGGED_IN: (userId: string, timestamp: Date) => ({
type: "USER_LOGGED_IN" as const,
userId,
timestamp
}),
PAYMENT_PROCESSED: (amount: number, currency: string) => ({
type: "PAYMENT_PROCESSED" as const,
amount,
currency
}),
ERROR_OCCURRED: (message: string, code: number) => ({
type: "ERROR_OCCURRED" as const,
message,
code
})
};
type Event = ReturnType<typeof EVENTS[keyof typeof EVENTS]>;
function handleEvent(event: Event) {
switch (event.type) {
case "USER_LOGGED_IN":
// TypeScript knows event.userId and event.timestamp exist
console.log(`User ${event.userId} logged in at ${event.timestamp}`);
break;
case "PAYMENT_PROCESSED":
// TypeScript knows event.amount and event.currency exist
console.log(`Payment of ${event.amount} ${event.currency}`);
break;
case "ERROR_OCCURRED":
// TypeScript knows event.message and event.code exist
console.log(`Error ${event.code}: ${event.message}`);
break;
}
}
// Usage is type-safe
handleEvent(EVENTS.USER_LOGGED_IN("user_123", new Date()));
Pattern 5: Making Invalid States Unrepresentable
This is where everything comes together. Design your types so that invalid states cannot be constructed.
Example: User Authentication State
Bad design:
interface AuthState {
isAuthenticated: boolean;
user: User | null;
token: string | null;
}
// All these invalid states are possible:
// { isAuthenticated: true, user: null, token: null }
// { isAuthenticated: false, user: someUser, token: "token" }
Good design:
type AuthState =
| { status: "anonymous" }
| { status: "authenticated"; user: User; token: string };
function renderHeader(auth: AuthState) {
if (auth.status === "authenticated") {
// TypeScript knows user and token exist
return <div>Welcome, {auth.user.name}</div>;
} else {
return <div>Please log in</div>;
}
}
Example: Feature Flags with Type-Safe Rollout
type FeatureConfig =
| { enabled: false }
| { enabled: true; config: { apiEndpoint: string; timeout: number } };
const FEATURES = {
newDashboard: { enabled: false },
advancedSearch: {
enabled: true,
config: { apiEndpoint: "/api/search/v2", timeout: 10000 }
}
} as const satisfies Record<string, FeatureConfig>;
type Features = typeof FEATURES;
function useFeature<K extends keyof Features>(
key: K
): Features[K] extends { enabled: true } ? Features[K]["config"] : null {
const feature = FEATURES[key];
if (feature.enabled) {
return feature.config as any;
}
return null as any;
}
// TypeScript knows this returns the config object
const searchConfig = useFeature("advancedSearch");
if (searchConfig) {
console.log(searchConfig.apiEndpoint);
}
// TypeScript knows this returns null
const dashboardConfig = useFeature("newDashboard");
Pattern 6: Recursive Types for Validation
Build type-safe validation that scales with your data structures.
type ValidationError = { field: string; message: string };
type ValidationResult<T> =
| { success: true; data: T }
| { success: false; errors: ValidationError[] };
type Validator<T> = (value: unknown) => ValidationResult<T>;
const isString: Validator<string> = (value) => {
if (typeof value === "string") {
return { success: true, data: value };
}
return { success: false, errors: [{ field: "root", message: "Must be string" }] };
};
const isNumber: Validator<number> = (value) => {
if (typeof value === "number") {
return { success: true, data: value };
}
return { success: false, errors: [{ field: "root", message: "Must be number" }] };
};
function isObject<T extends Record<string, Validator<any>>>(
validators: T
): Validator<{ [K in keyof T]: T[K] extends Validator<infer U> ? U : never }> {
return (value) => {
if (typeof value !== "object" || value === null) {
return { success: false, errors: [{ field: "root", message: "Must be object" }] };
}
const obj = value as Record<string, unknown>;
const errors: ValidationError[] = [];
const data: any = {};
for (const key in validators) {
const result = validators[key](obj[key]);
if (result.success) {
data[key] = result.data;
} else {
errors.push(...result.errors.map(e => ({ ...e, field: `${key}.${e.field}` })));
}
}
if (errors.length > 0) {
return { success: false, errors };
}
return { success: true, data };
};
}
const userValidator = isObject({
name: isString,
age: isNumber
});
const result = userValidator({ name: "John", age: 30 });
if (result.success) {
// TypeScript knows result.data is { name: string; age: number }
console.log(result.data.name);
}
Practical Tips for Type-Safe Design
1. Start with States, Not Fields
When designing a type, list all possible states first, then figure out what data belongs to each state.
2. Use the Compiler as a TODO List
If you refactor a discriminated union, the compiler will show you every place that needs updating. This is a feature, not a bug.
3. Don’t Fight the Type System
If the types feel awkward, your domain model might need rethinking. Types should clarify, not obscure.
4. Progressive Enhancement
You don’t need to make everything type-safe on day one. Start with critical paths (payments, authentication) and expand outward.
5. Document with Types
A well-designed type is better documentation than comments:
// Bad: Comments lie, code doesn't
type Payment = { status: string }; // Should be "pending" or "completed"
// Good: Type enforces the truth
type Payment = { status: "pending" | "completed" };
The Payoff
Type-safe design isn’t about being pedantic. It’s about:
- Catching bugs at compile time instead of in production
- Enabling fearless refactoring because the compiler shows you what breaks
- Self-documenting code where types tell you what’s possible
- Reducing cognitive load because invalid states aren’t even considered
- Onboarding new team members who can explore your API through autocomplete
The investment in designing types upfront pays dividends every time someone (including future you) interacts with that code.
Conclusion
The best code is code that makes bugs impossible. TypeScript’s type system is powerful enough to encode complex business rules, but it requires thinking differently about design. Instead of thinking “what fields does this object need?”, think “what states can this object be in?”
Make invalid states unrepresentable. Use discriminated unions for mutually exclusive states. Use branded types to prevent mixing incompatible values. Use phantom types to track compile-time information. Let the compiler be your ally.
Your future self—and your production error logs—will thank you.
What type-safe patterns have saved you from bugs? Share your experiences in the comments.