Search IOCombats

Search challenges, guides, questions and articles

TypeScript Tricks Working Frontend Engineers Actually Use: satisfies, Branded Types, and Exhaustive Unions
TypeScriptType SafetyFrontend EngineeringInterview PrepDesign Patterns

TypeScript Tricks Working Frontend Engineers Actually Use: satisfies, Branded Types, and Exhaustive Unions

By Ghazi Khan | Sep 18, 2026 - 9 min read

Most frontend engineers use TypeScript for years without touching half of what its type system can actually catch. You annotate props, you type your API responses, you fight the occasional any, and you move on. But there is a set of patterns, small, specific, and easy to adopt one at a time, that turn TypeScript from a linter with opinions into a tool that catches entire categories of bugs before your code runs.

This post covers four of them: the satisfies operator, branded types for primitive obsession, exhaustiveness checking for discriminated unions, and template literal types for string-shaped data. None of these require a framework change or a build tool swap. All four show up in interviews at product companies that take frontend engineering seriously, because they signal that you understand TypeScript's type system rather than just its syntax.

The problem with type annotations: they throw away information

When you write const config: Config = {...}, TypeScript checks that your object matches Config, then it forgets the specific values you wrote. Every property gets widened to its declared type. If Config.env is typed as string, TypeScript no longer remembers that you wrote "production", it just knows it is some string.

interface Config {
  env: string;
  retries: number;
}

const config: Config = {
  env: 'production',
  retries: 3,
};

// config.env has type `string`, not `"production"`.
// Autocomplete on config.env gives you nothing useful.

This matters more than it looks like it should, because a lot of real bugs come from that widening. If you have a Record<string, Handler> map and a typo in one of the keys, a plain type annotation will not catch it, because the annotation only checks the shape, not the literal keys.

satisfies keeps the literal type and still checks the contract

The satisfies operator, stable since TypeScript 4.9, checks your value against a type without widening it. The variable keeps its inferred, narrower type, while TypeScript still verifies the value is assignable to the type you named.

type Handler = (payload: unknown) => void;

const handlers = {
  login: (payload) => console.log('login', payload),
  logout: (payload) => console.log('logout', payload),
} satisfies Record<string, Handler>;

// handlers.login is known to exist and is typed as Handler.
// A typo like `logn: ...` fails the satisfies check immediately.
// handlers still has the literal keys "login" | "logout" for autocomplete.

The rule that trips people up: do not combine a type annotation with satisfies on the same declaration. Writing const x: Theme = {...} satisfies Theme makes the annotation win, so the value still gets widened to Theme and you lose the narrowing satisfies was supposed to give you. Drop the colon, keep only the trailing satisfies.

This pattern matters even more with discriminated unions, since annotating a union member widens its discriminator field to the union's base type, which silently breaks the switch-based narrowing you built the union for in the first place.

Diagram
100%
flowchart LR A["const x: T = value"] --> B["value is widened to T"] B --> C["Literal types lost, discriminator widened"] D["const x = value satisfies T"] --> E["value keeps its inferred literal type"] E --> F["T contract is still checked"] F --> G["Discriminator stays narrow, switch narrowing works"] style C fill:#3a1f1f,stroke:#b04a4a,color:#eee style G fill:#1f3a24,stroke:#4ab06a,color:#eee
visualized byIOCombats

Exhaustiveness checking: making the compiler enforce every case

Discriminated unions are the standard way to model "one of several shapes" in TypeScript, a loading state, a network response, a form field's validation result. The risk is that someone adds a new variant to the union six months later and forgets to update every switch statement that handles it. Without exhaustiveness checking, that is a silent runtime bug: the new case falls through and nothing happens.

The fix uses a property of the never type: never is assignable to every type, but nothing else is assignable to never. That asymmetry is what makes exhaustiveness checking possible.

type RequestState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: string }
  | { status: 'error'; message: string };

function renderState(state: RequestState): string {
  switch (state.status) {
    case 'idle':
      return 'Waiting to start';
    case 'loading':
      return 'Loading...';
    case 'success':
      return `Loaded: ${state.data}`;
    case 'error':
      return `Failed: ${state.message}`;
    default:
      return assertNever(state);
  }
}

function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}

At the default branch, TypeScript has already narrowed away every handled case. If all four variants are covered, whatever reaches default has type never, and it satisfies the parameter of assertNever. Add a fifth variant to RequestState and forget to handle it in the switch, and the call to assertNever(state) fails to compile, because state is no longer never, it is the leftover unhandled variant. The bug becomes a build failure instead of a support ticket.

Diagram
100%
sequenceDiagram participant Dev as Developer participant Union as RequestState union participant Switch as switch statement participant Compiler as TS Compiler Dev->>Union: Adds new variant "stale" Dev->>Switch: Forgets to add a case for "stale" Switch->>Compiler: default branch receives leftover type Compiler->>Compiler: leftover type is not never Compiler-->>Dev: Type error at assertNever(state) Dev->>Switch: Adds case 'stale' handler Switch->>Compiler: default branch now receives never Compiler-->>Dev: Build passes
visualized byIOCombats

Pair this with the @typescript-eslint/switch-exhaustiveness-check ESLint rule if your team wants the same guarantee flagged directly in the editor, before a build even runs.

Branded types: fixing primitive obsession without a runtime cost

TypeScript's type system is structural, not nominal. Two types with the same shape are interchangeable, even if they mean different things. This becomes a real bug source with IDs: UserId and OrderId are both just string, so nothing stops you from passing a user ID where an order ID belongs.

function getOrder(orderId: string) {
  /* ... */
}

const userId: string = 'user_123';
getOrder(userId); // compiles fine, and it is wrong

Branded types close this gap by intersecting the primitive with a unique, compile-time-only tag. The tag never exists at runtime, it costs nothing in bundle size or performance, it only exists to make the type checker treat two strings as incompatible.

type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;

function toUserId(raw: string): UserId {
  return raw as UserId;
}

function getOrder(orderId: OrderId) {
  /* ... */
}

const userId = toUserId('user_123');
getOrder(userId); // compile error: UserId is not assignable to OrderId

The cast (as UserId) should live in exactly one place, typically the function that validates and constructs the ID from raw input (a parsed API response, a route param). Everywhere else in the codebase, the compiler enforces that a UserId and an OrderId are never mixed up, even though at runtime they are both plain strings.

This same pattern extends past IDs: validated emails, non-empty strings, positive numbers, anything where "the type checks out structurally" is not the same as "the value is actually valid" in your domain.

Template literal types: typing string shapes, not just string values

Template literal types let you describe the pattern of a string at the type level, not just that something is a string. This is the right tool for API routes, CSS custom properties, event names, and i18n keys, anywhere you control the format and want autocomplete plus compile-time checking on it.

type PlanId = 'free' | 'pro' | 'team';
type PlanRoute = `/api/plans/${PlanId}`;

function fetchPlan(route: PlanRoute) {
  /* ... */
}

fetchPlan('/api/plans/pro'); // fine
fetchPlan('/api/plans/enterprise'); // compile error, not a valid PlanId

Combined with conditional types, you can go further and extract typed parameters out of a route string:

type ExtractParams<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<Rest>
    : T extends `${string}:${infer Param}`
    ? Param
    : never;

type Params = ExtractParams<'/users/:userId/posts/:postId'>;
// Params = "userId" | "postId"

The honest caveat: template literal types are for strings your codebase produces and controls. Data arriving from outside, a form submission, a webhook payload, a third-party API, still needs runtime validation (Zod, Valibot, or hand-written parsing). A template literal type has zero effect at runtime; it disappears the moment the code compiles.

Comparing the four patterns

PatternSolvesRuntime costWhere it shows up
satisfiesType annotations widening literals and discriminatorsNoneConfig objects, handler maps, discriminated union values
Exhaustiveness checkingForgotten cases when a union growsNone (throws only if reached, which it shouldn't be)Reducers, state machines, API response handling
Branded typesStructurally identical but semantically different primitivesNoneIDs, validated strings, currency amounts
Template literal typesLoosely typed strings that actually follow a patternNoneRoutes, CSS variables, event names, i18n keys

None of these carry a runtime cost because TypeScript's type system is fully erased at compile time. That is also exactly why none of them replace validation at your actual trust boundaries, the network, user input, localStorage. They are compile-time guarantees for the code you control, not runtime guarantees for the data you don't.

Practical takeaway

Adopt these in order of effort. satisfies is a one-line change with no new types to define, add it anywhere you currently write const x: Type = {...} for a config object or a map. Exhaustiveness checking is a small helper function (assertNever) you write once and reuse everywhere you switch on a discriminated union. Branded types and template literal types take more upfront design, so reach for them specifically where primitive obsession or loosely typed strings have already caused a bug or a confusing bug report.

In an interview, being able to explain why satisfies preserves narrowing while a type annotation does not, or why never is the right tool for exhaustiveness checking, signals a level of TypeScript fluency well past "I use interfaces and generics." It shows you understand the type system's actual mechanics, not just its common syntax.

Conclusion

TypeScript's biggest wins rarely come from typing more things, they come from typing things more precisely. satisfies keeps information type annotations throw away. Exhaustiveness checking turns a forgotten union case into a build failure. Branded types stop structurally identical primitives from being swapped by mistake. Template literal types bring compile-time checking to strings that follow a pattern. Each one is small enough to introduce into an existing codebase this week.

Advertisement

Ready to practice?

Test your skills with our interactive UI challenges and build your portfolio.

Start Coding Challenge