Type-Safe By Design: Architecting Applications That Make Bugs Impossible
12 mins read

Type-Safe By Design: Architecting Applications That Make Bugs Impossible


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?
Enter fullscreen mode

Exit fullscreen mode

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" });
Enter fullscreen mode

Exit fullscreen mode

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 };
Enter fullscreen mode

Exit fullscreen mode

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>;
  }
}
Enter fullscreen mode

Exit fullscreen mode

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);
  }
}
Enter fullscreen mode

Exit fullscreen mode



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 fullscreen mode

Exit fullscreen mode

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);
Enter fullscreen mode

Exit fullscreen mode



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");
}
Enter fullscreen mode

Exit fullscreen mode

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));
Enter fullscreen mode

Exit fullscreen mode

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;
}
Enter fullscreen mode

Exit fullscreen mode



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" }
};
Enter fullscreen mode

Exit fullscreen mode



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);
Enter fullscreen mode

Exit fullscreen mode



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 }
Enter fullscreen mode

Exit fullscreen mode



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`
Enter fullscreen mode

Exit fullscreen mode



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()));
Enter fullscreen mode

Exit fullscreen mode



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" }
Enter fullscreen mode

Exit fullscreen mode

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>;
  }
}
Enter fullscreen mode

Exit fullscreen mode



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");
Enter fullscreen mode

Exit fullscreen mode



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);
}
Enter fullscreen mode

Exit fullscreen mode



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" };
Enter fullscreen mode

Exit fullscreen mode



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.



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *