Kodokon kodokon.com

Production patterns: branded types, satisfies, and .d.ts

Apply the expert patterns that secure a real codebase: simulated nominality, validation without widening, and ambient declarations.

11 min · 3 questions

Open this lesson in Kodokon

TypeScript's typing is structural: two types with the same shape are interchangeable, so a user-id string is indistinguishable from any other string. Branding simulates nominal typing by intersecting the base type with a phantom property whose key is a unique symbol: no real value ever has it, but the compiler now tells each brand apart.

TYPESCRIPT
declare const brand: unique symbol;

type Brand<T, Name extends string> =
  T & { readonly [brand]: Name };

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

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

declare function loadUser(id: UserId): void;

loadUser(asUserId("u_42")); // OK
// loadUser("u_42"); // error: bare string rejected
Simulated nominality, at zero runtime cost.

The satisfies operator (TypeScript 4.9) fills a precise gap. A : T annotation widens the variable's type to T and loses the inferred literals; an as T disables part of the checks. satisfies T verifies that the expression conforms to T without touching the inferred type, which stays as precise as possible. It is the ideal tool for configuration objects: full validation, precision intact.

TYPESCRIPT
type RouteDef = { path: string; auth?: boolean };

const routes = {
  home: { path: "/" },
  admin: { path: "/admin", auth: true },
} satisfies Record<string, RouteDef>;

routes.admin.auth;
// literal type true: the inferred precision is
// preserved, and an unknown key would be rejected
satisfies: strict checking without widening.

A .d.ts file contains ambient declarations exclusively: it describes values that will exist at runtime, without emitting a single line of JavaScript. To extend the global scope from a module, the declare global block is mandatory - and the specification only allows it in a file that actually is a module, hence the export {} idiom at the top of the file.

TYPESCRIPT
export {};

declare global {
  interface Window {
    analytics: { track(event: string): void };
  }
}
env.d.ts: the export {} turns the file into a module.

For a JavaScript dependency without published types, declare module creates an ambient module: the compiler will use your declaration for every import of the package. Watch out for the scope of skipLibCheck: when enabled, it ignores errors from all .d.ts files, including yours; so verify your declarations through type tests placed in ordinary .ts files.

TYPESCRIPT
declare module "legacy-lib" {
  export interface InitOptions {
    debug?: boolean;
  }
  export function init(options?: InitOptions): void;
}
legacy-lib.d.ts: typing a package without published types.

Knowledge check

Make sure you remember the key points of this lesson.

  1. What is the difference between satisfies T and a : T annotation?
    • satisfies widens the expression's type to T
    • satisfies checks conformance to T while keeping the more precise inferred type
    • satisfies disables checks, like as
    • None: the two forms are equivalent
  2. At runtime, what does a branded value built with Brand<string, ...> contain?
    • A string along with a symbol property
    • A plain string: the brand only exists at compile time
    • An object wrapping the original string
  3. Why add export {} at the top of a .d.ts file that uses declare global?
    • To export the global types to other files
    • To turn the file into a module, a condition required by declare global
    • To automatically enable strict mode
    • It is purely decorative