Home / Articles

Six TypeScript Techniques That Turn Types into Real Bug Prevention

Learn how satisfies, tagged unions, never checks, unknown, derived types and branded IDs make TypeScript catch real bugs at compile time instead of in production.

Publisher: REACTAPP.TOP7 min read1627 words

This article is published in English.

Sources: TypeScript Can Already Do This. Almost Nobody Uses It.

A project can list TypeScript in package.json, declare interfaces with a dozen optional fields, and still crash with "cannot read properties of undefined". Having the compiler installed is not the same as making it work for you, and adding : string to every variable does little on its own. The six techniques below are what experienced teams use to let the type checker carry real weight, each with a snippet and the reasoning behind it.

Validate objects with satisfies instead of an annotation

The usual way to make sure a config object has the right shape is to annotate it:

const config: Record<string, number> = {
  timeout: 3000,
  retries: 3,
};
config.timeout.toFixed(2); // fine

This compiles, but the annotation replaces whatever TypeScript would have inferred. The variable's type is now exactly Record<string, number>, so the compiler no longer knows which keys the object actually has. config.timeout is just "some number under some string key", a typo such as config.timout is accepted without complaint, and with noUncheckedIndexedAccess enabled every property read becomes number | undefined.

The satisfies operator, available since TypeScript 4.9, checks the object against the type without replacing the inferred type:

const config = {
  timeout: 3000,
  retries: 3,
} satisfies Record<string, number>;

config.timeout; // TypeScript still knows this is literally 3000

You get the validation (a string value would be rejected) and you keep the precise shape: TypeScript knows the object has exactly timeout and retries, and misspelled keys become errors. One correction to a common claim, including the comment in the snippet: satisfies alone does not keep timeout as the literal type 3000. Properties of a mutable object are still widened to number. If you need the literal values too, combine the two operators as as const satisfies Record<string, number>, which makes the object readonly and preserves every literal. Many developers still reach for the annotation simply because it was the only option for years.

Model variants as tagged unions, not optional fields

When a value can take several shapes, a tempting shortcut is to make every field optional:

interface ApiResponse {
  data?: User[];
  error?: string;
  loading?: boolean;
}

The type now permits every combination, including data and error together, or neither with loading false. The rule that only one state applies at a time lives in people's heads, and the compiler cannot enforce it.

A discriminated union (tagged union) encodes that rule. Each variant carries a literal status tag and only the fields that belong to it:

type ApiResponse =
  | { status: 'loading' }
  | { status: 'success'; data: User[] }
  | { status: 'error'; error: string };

function render(response: ApiResponse) {
  switch (response.status) {
    case 'loading':
      return <Spinner />;
    case 'success':
      return <UserList users={response.data} />; // data exists, guaranteed
    case 'error':
      return <ErrorBanner message={response.error} />;
  }
}

Inside each case, TypeScript narrows response to the matching variant. response.data is only reachable in the success branch, because the other variants have no data property at all. A whole category of "is this defined here?" bugs disappears before the code runs, and the same pattern works for reducer actions, form states and API results.

Make missing cases fail the build with never

Tagged unions have one remaining hole. Suppose that months later someone adds a 'forbidden' status. Nothing forces every switch over ApiResponse to be updated, so the new case falls through silently and surfaces as a production bug.

An exhaustiveness check closes that gap. The default branch assigns the narrowed value to a variable typed never:

function render(response: ApiResponse) {
  switch (response.status) {
    case 'loading':
      return <Spinner />;
    case 'success':
      return <UserList users={response.data} />;
    case 'error':
      return <ErrorBanner message={response.error} />;
    default:
      const exhaustiveCheck: never = response;
      throw new Error(`Unhandled status: ${exhaustiveCheck}`);
  }
}

When every variant is handled, nothing remains for response to be in the default branch, so its type is never and the assignment compiles. Add 'forbidden' without a matching case, and response there becomes { status: 'forbidden' }, which is not assignable to never, so the build fails and points straight at the forgotten branch. The throw also protects you at runtime if unexpected data arrives anyway. Wrapping the default body in braces keeps linters that flag declarations inside case clauses happy.

Use unknown at the edges of your application

any switches type checking off. unknown keeps it on and forces you to prove what a value is before using it. That is exactly the behavior you want wherever untrusted data enters: API responses, JSON.parse results, webhook payloads.

Here the fetch helper deliberately returns Promise<unknown>. Reading .name directly is a compile error, and it becomes legal only inside a type guard:

async function fetchUser(id: string): Promise<unknown> {
  const res = await fetch(`/api/user/${id}`);
  return res.json();
}

const raw = await fetchUser('123');
raw.name; // compile error, unknown has no properties
if (isUser(raw)) {
  raw.name; // fine, narrowed to User inside the guard
}

isUser is a user-defined type guard with the signature function isUser(x: unknown): x is User, and its body must genuinely check the shape at runtime. With unknown, the compiler refuses property access until something has verified the data. With any, validation is optional, and optional checks tend to be skipped until a malformed response reaches a component that assumed a field always exists. For larger payloads, a schema library such as Zod can generate both the runtime check and the type. More patterns for this are covered in six type-safe replacements for any.

Derive types from code instead of maintaining copies

If a function already builds a value, you do not need a separate hand-written interface for its result. TypeScript can extract it:

function createOrderPayload(order: Order) {
  return {
    id: order.id,
    total: order.total,
    lineItems: order.items.map(i => ({ sku: i.sku, qty: i.qty })),
  };
}

type OrderPayload = ReturnType<typeof createOrderPayload>;

ReturnType<typeof createOrderPayload> reads the function's inferred return type. Change the function, and OrderPayload follows everywhere it is used. A manually maintained interface, by contrast, drifts: after a few refactors it claims a field exists that the function stopped returning long ago, and the compiler trusts the stale interface.

The same idea applies to components from libraries you do not own. React.ComponentProps<typeof Button> yields the exact props a third-party Button accepts, taken from its own type definitions, so there is no shadow copy to update when the library adds a prop. Related helpers like Parameters, Awaited and indexed access types (OrderPayload['lineItems'][number]) extend the approach; see TypeScript's built-in utility types for an overview.

Brand primitive types so IDs cannot be swapped

By default a UserId and an OrderId are both plain strings, so passing an order ID to a function expecting a user ID compiles fine. The query simply returns nothing, and someone spends time working out why.

Branding intersects the primitive with a phantom property so the two become incompatible:

type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };

function getUser(id: UserId) { /* ... */ }
const orderId = 'ord_123' as OrderId;
getUser(orderId); // compile error: OrderId is not assignable to UserId

The __brand property never exists at runtime; it lives only in the type system, which is enough for TypeScript to treat the two types as distinct. Values are created through a small constructor or validation function (the as OrderId cast in the example stands in for that), and from then on every accidental mix-up is a compile error at zero runtime cost. Keep the casts inside those few constructors so the brand actually means "this value was checked".

Wrapping up

None of this needs a new framework or library; it is the TypeScript you already have, used deliberately:

  • satisfies validates without discarding the inferred shape; add as const when literals matter.
  • Tagged unions make impossible states unrepresentable.
  • A never check turns a forgotten case into a build error.
  • unknown at the boundaries forces validation before use.
  • Derived types stay in sync with the code that produces the values.
  • Brands keep semantically different strings apart.

Pick one technique and apply it in your next pull request. Each one shifts a class of bugs from runtime to compile time, which is the practical difference between having TypeScript installed and having it protect your code.