Přeskočit na obsah
·19 min čtení

Pokročilé typy v TypeScriptu: Podmíněné, mapované a šablonové literálové kouzla

Hluboký ponor do nejsilnějších typových vlastností TypeScriptu — podmíněné typy, mapované typy, klíčové slovo infer a šablonové literálové typy. Reálné patterny, které fakt použiješ.

Sdílet:X / TwitterLinkedIn

Většina TypeScript vývojářů se zasekne na stejném místě. Znají interfaces, generika, union typy. Umí otypovat React komponentu. Umí zlikvidovat červené vlnovky. Ale typový systém berou jako nutné zlo — něco, co existuje proto, aby zabraňovalo bugům, ne jako nástroj, který jim aktivně pomáhá navrhovat lepší software.

Na tomhle plató jsem se držel asi dva roky. Ven mě vytáhlo zjištění, že typový systém TypeScriptu je sám o sobě programovací jazyk. Má podmínky, cykly, pattern matching, rekurzi a manipulaci s řetězci. Jakmile si tohle zvnitřníš, všechno se změní. Přestaneš bojovat s kompilátorem a začneš s ním spolupracovat.

Tenhle článek pokrývá typové vlastnosti, které neustále používám v produkčním kódu. Žádná akademická cvičení — reálné patterny, které mě zachránily před reálnými bugy.

Myšlení na úrovni typů#

Než se ponoříme do syntaxe, chci přerámovat způsob, jakým přemýšlíš o typech.

V programování na úrovni hodnot píšeš funkce, které transformují data za běhu:

typescript
function extractIds(users: User[]): number[] {
  return users.map(u => u.id);
}

V programování na úrovni typů píšeš typy, které transformují jiné typy v době kompilace:

typescript
type ExtractIds<T extends { id: unknown }[]> = T[number]["id"];

Mentální model je stejný — vstup, transformace, výstup. Rozdíl je v tom, že kód na úrovni typů běží při kompilaci, ne za běhu. Negeneruje žádný JavaScript. Jeho jediný účel je učinit nemožné stavy nereprezentovatelné.

Pokud jsi někdy napsal funkci, kde tři ze čtyř parametrů dávají smysl jen v určitých kombinacích, nebo kde návratový typ závisí na tom, co předáš, tak jsi programování na úrovni typů už potřeboval. Jen jsi možná nevěděl, že TypeScript to umí vyjádřit.

Podmíněné typy#

Podmíněné typy jsou if/else typového systému. Syntaxe vypadá jako ternární výraz:

typescript
type IsString<T> = T extends string ? true : false;
 
type A = IsString<"hello">; // true
type B = IsString<42>;      // false
type C = IsString<string>;  // true

Klíčové slovo extends tady neznamená dědičnost. Znamená „je přiřaditelný do." Představ si to jako „vejde se T do tvaru string?"

Vytváření vlastních utility typů#

Pojďme přestavět některé vestavěné utility typy TypeScriptu, abychom pochopili, jak fungují.

NonNullable — odstraní null a undefined z union:

typescript
type MyNonNullable<T> = T extends null | undefined ? never : T;
 
type Test = MyNonNullable<string | null | undefined>;
// Výsledek: string

Typ never je „prázdná množina" — odstraňuje něco z union. Když se větev podmíněného typu vyhodnotí na never, ten člen union prostě zmizí.

Extract — ponechá pouze členy union, které odpovídají omezení:

typescript
type MyExtract<T, U> = T extends U ? T : never;
 
type Numbers = Extract<string | number | boolean, number | boolean>;
// Výsledek: number | boolean

Exclude — opak, odstraní odpovídající členy:

typescript
type MyExclude<T, U> = T extends U ? never : T;
 
type WithoutStrings = Exclude<string | number | boolean, string>;
// Výsledek: number | boolean

Distributivní podmíněné typy#

Tady to začíná být zajímavé — a kde se většina lidí ztrácí. Když předáš union typ podmíněnému typu, TypeScript distribuuje podmínku přes každého člena union zvlášť.

typescript
type ToArray<T> = T extends unknown ? T[] : never;
 
type Result = ToArray<string | number>;
// Mohl bys čekat: (string | number)[]
// Skutečný výsledek: string[] | number[]

TypeScript nevyhodnocuje (string | number) extends unknown. Místo toho vyhodnotí string extends unknown a number extends unknown zvlášť a pak sjednotí výsledky.

Proto Extract a Exclude fungují tak, jak fungují. Distribuce se děje automaticky, když je T „nahý" typový parametr (není zabalený do ničeho).

Pokud chceš distribuci zabránit, zabal obě strany do tuple:

typescript
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
 
type Result = ToArrayNonDist<string | number>;
// Výsledek: (string | number)[]

Tohle mě popálilo víckrát, než bych chtěl přiznat. Pokud tvůj podmíněný typ dává neočekávané výsledky s union vstupy, distribuce je téměř vždy důvod.

Praktický příklad: Handler API odpovědí#

Tady je pattern, který používám při práci s API odpověďmi, které mohou buď uspět, nebo selhat:

typescript
type ApiResponse<T> =
  | { status: "success"; data: T; error: never }
  | { status: "error"; data: never; error: string };
 
type ExtractData<R> = R extends { status: "success"; data: infer D } ? D : never;
type ExtractError<R> = R extends { status: "error"; error: infer E } ? E : never;
 
type UserResponse = ApiResponse<{ id: number; name: string }>;
 
type UserData = ExtractData<UserResponse>;
// Výsledek: { id: number; name: string }
 
type UserError = ExtractError<UserResponse>;
// Výsledek: string

Tohle je čistější než ruční psaní type guardů pro každý tvar odpovědi.

Klíčové slovo infer#

infer je pattern matching pro typy. Umožňuje deklarovat typovou proměnnou uvnitř podmíněného typu, kterou TypeScript sám odvodí.

Představ si to jako: „Vím, že tenhle typ má určitý tvar. Vytáhni z něj tu část, která mě zajímá."

Extrakce návratových typů#

typescript
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
 
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // boolean

TypeScript vidí pattern (...args) => něco, porovná tvou funkci s ním a přiřadí návratový typ do R.

Extrakce typů parametrů#

typescript
type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;
 
type Fn = (x: number, y: string) => void;
type Params = MyParameters<Fn>; // [x: number, y: string]

Rozbalování Promises#

Tohle používám pořád při práci s async funkcemi:

typescript
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
 
type A = UnpackPromise<Promise<string>>;  // string
type B = UnpackPromise<Promise<number[]>>; // number[]
type C = UnpackPromise<string>;            // string (passthrough)

Pro hluboce vnořené promises:

typescript
type DeepUnpackPromise<T> = T extends Promise<infer U>
  ? DeepUnpackPromise<U>
  : T;
 
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Výsledek: number

Extrakce typů prvků pole#

typescript
type ElementOf<T> = T extends (infer E)[] ? E : never;
 
type Items = ElementOf<string[]>;         // string
type Mixed = ElementOf<(string | number)[]>; // string | number

Vícenásobné infer v jednom typu#

Můžeš použít infer vícekrát k extrakci různých částí:

typescript
type FirstAndLast<T extends unknown[]> =
  T extends [infer First, ...unknown[], infer Last]
    ? { first: First; last: Last }
    : never;
 
type Result = FirstAndLast<[1, 2, 3, 4]>;
// Výsledek: { first: 1; last: 4 }

Reálný příklad: Typově bezpečná extrakce eventů#

Tady je pattern, který používám v event-driven systémech:

typescript
type EventMap = {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "order:created": { orderId: string; total: number };
  "order:shipped": { orderId: string; trackingId: string };
};
 
type EventName = keyof EventMap;
type EventPayload<E extends EventName> = EventMap[E];
 
// Extrakce všech user-related eventů
type UserEvents = Extract<EventName, `user:${string}`>;
// Výsledek: "user:login" | "user:logout"
 
// Získání payloadu pouze pro user eventy
type UserEventPayloads = EventPayload<UserEvents>;
// Výsledek: { userId: string; timestamp: number } | { userId: string }

Mapované typy#

Mapované typy ti umožňují vytvářet nové objektové typy transformací každé vlastnosti existujícího typu. Jsou to map() typového systému.

Základy#

typescript
type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};
 
type Partial<T> = {
  [K in keyof T]?: T[K];
};

keyof T ti dá union všech klíčů vlastností. [K in ...] iteruje přes každý z nich. T[K] je typ dané vlastnosti.

Utility typy od nuly#

Required — učiní všechny vlastnosti povinnými:

typescript
type MyRequired<T> = {
  [K in keyof T]-?: T[K];
};

Syntaxe -? odstraňuje volitelný modifikátor. Podobně -readonly odstraňuje readonly.

Pick — vybere konkrétní vlastnosti:

typescript
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};
 
interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}
 
type PublicUser = MyPick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string }

Omit — odstraní konkrétní vlastnosti:

typescript
type MyOmit<T, K extends keyof T> = {
  [P in Exclude<keyof T, K>]: T[P];
};
 
type SafeUser = MyOmit<User, "password">;
// { id: number; name: string; email: string }

Record — vytvoří objektový typ s konkrétními klíči a typy hodnot:

typescript
type MyRecord<K extends string | number | symbol, V> = {
  [P in K]: V;
};
 
type StatusMap = MyRecord<"active" | "inactive" | "banned", boolean>;
// { active: boolean; inactive: boolean; banned: boolean }

Přemapování klíčů s as#

TypeScript 4.1 zavedl přemapování klíčů, které ti umožňuje transformovat názvy vlastností:

typescript
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
 
interface Person {
  name: string;
  age: number;
  email: string;
}
 
type PersonGetters = Getters<Person>;
// {
//   getName: () => string;
//   getAge: () => number;
//   getEmail: () => string;
// }

Můžeš také filtrovat vlastnosti přemapováním na never:

typescript
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};
 
type StringProps = OnlyStrings<Person>;
// { name: string; email: string }

Tohle je neuvěřitelně silné. Současně iteruješ přes vlastnosti, filtruješ je a transformuješ jejich názvy — to vše na úrovni typů.

Praktický příklad: Typy pro validaci formulářů#

Tenhle pattern jsem použil v několika knihovnách pro formuláře:

typescript
interface FormFields {
  username: string;
  email: string;
  age: number;
  bio: string;
}
 
type ValidationErrors<T> = {
  [K in keyof T]?: string[];
};
 
type TouchedFields<T> = {
  [K in keyof T]?: boolean;
};
 
type DirtyFields<T> = {
  [K in keyof T]?: boolean;
};
 
type FormState<T> = {
  values: T;
  errors: ValidationErrors<T>;
  touched: TouchedFields<T>;
  dirty: DirtyFields<T>;
  isValid: boolean;
  isSubmitting: boolean;
};
 
// Teď je stav formuláře plně otypovaný na základě polí
type MyFormState = FormState<FormFields>;

Přidej pole do FormFields a každý související typ se automaticky aktualizuje. Odstraň pole a kompilátor zachytí každou referenci. Tohle je přesně ten druh věcí, který zabraňuje celým kategoriím bugů.

Šablonové literálové typy#

Šablonové literálové typy ti umožňují manipulovat s řetězci na úrovni typů. Používají stejnou syntaxi s backticky jako JavaScript template literály, ale pro typy.

Základní manipulace s řetězci#

typescript
type Greeting = `Hello, ${string}`;
 
const a: Greeting = "Hello, world";    // OK
const b: Greeting = "Hello, TypeScript"; // OK
// const c: Greeting = "Hi, world";    // Chyba!

TypeScript také poskytuje vestavěné typy pro manipulaci s řetězci:

typescript
type Upper = Uppercase<"hello">;     // "HELLO"
type Lower = Lowercase<"HELLO">;     // "hello"
type Cap = Capitalize<"hello">;      // "Hello"
type Uncap = Uncapitalize<"Hello">;  // "hello"

Patterny pro názvy eventů#

Tady šablonové literálové typy opravdu vyniknou. Tady je pattern, který používám pro typově bezpečné event systémy:

typescript
type EventNames<T extends string> = `${T}Changed` | `${T}Deleted` | `${T}Created`;
 
type ModelEvents = EventNames<"user" | "order" | "product">;
// "userChanged" | "userDeleted" | "userCreated"
// | "orderChanged" | "orderDeleted" | "orderCreated"
// | "productChanged" | "productDeleted" | "productCreated"

Devět eventů ze dvou typových parametrů. A všechny jsou typově bezpečné.

CSS-in-TypeScript patterny#

Šablonové literálové typy dokážou vynutit CSS patterny na úrovni typů:

typescript
type CSSUnit = "px" | "rem" | "em" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;
 
function setWidth(value: CSSValue): void {
  // implementace
}
 
setWidth("100px");   // OK
setWidth("2.5rem");  // OK
setWidth("50%");     // OK
// setWidth("100");  // Chyba: neplatná CSS hodnota
// setWidth("big");  // Chyba

Parsování řetězců se šablonovými literály#

Šablonové literálové typy můžeš použít s infer k parsování řetězců:

typescript
type ParseRoute<T extends string> =
  T extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? Param | ParseRoute<`/${Rest}`>
    : T extends `${infer _Start}:${infer Param}`
      ? Param
      : never;
 
type RouteParams = ParseRoute<"/users/:userId/posts/:postId">;
// Výsledek: "userId" | "postId"

Tohle je základ toho, jak fungují typově bezpečné routovací knihovny jako tRPC. Řetězec /users/:userId/posts/:postId je parsován na úrovni typů k extrakci názvů parametrů.

Kombinace šablonových literálů s mapovanými typy#

Tady se věci stávají opravdu mocnými:

typescript
type EventHandlers<T extends Record<string, unknown>> = {
  [K in keyof T as `on${Capitalize<string & K>}Change`]: (
    newValue: T[K],
    oldValue: T[K]
  ) => void;
};
 
interface ThemeConfig {
  color: string;
  fontSize: number;
  darkMode: boolean;
}
 
type ThemeHandlers = EventHandlers<ThemeConfig>;
// {
//   onColorChange: (newValue: string, oldValue: string) => void;
//   onFontSizeChange: (newValue: number, oldValue: number) => void;
//   onDarkModeChange: (newValue: boolean, oldValue: boolean) => void;
// }

Z jednoduchého config rozhraní získáš plně otypované event handlery se správnými typy parametrů. Tohle je ten druh typového kódu, který reálně šetří vývojářský čas.

Pokročilé: Typy cest s tečkovou notací#

Tady je pokročilejší pattern — generování všech možných cest s tečkovou notací skrz vnořený objekt:

typescript
type DotPaths<T, Prefix extends string = ""> = T extends object
  ? {
      [K in keyof T & string]: T[K] extends object
        ? `${Prefix}${K}` | DotPaths<T[K], `${Prefix}${K}.`>
        : `${Prefix}${K}`;
    }[keyof T & string]
  : never;
 
interface Config {
  database: {
    host: string;
    port: number;
    credentials: {
      username: string;
      password: string;
    };
  };
  server: {
    port: number;
  };
}
 
type ConfigPaths = DotPaths<Config>;
// "database" | "database.host" | "database.port"
// | "database.credentials" | "database.credentials.username"
// | "database.credentials.password" | "server" | "server.port"

Teď můžeš napsat funkci getConfig, která automaticky doplňuje každou možnou cestu a vrací správný typ pro každou z nich:

typescript
type GetValueByPath<T, P extends string> =
  P extends `${infer Key}.${infer Rest}`
    ? Key extends keyof T
      ? GetValueByPath<T[Key], Rest>
      : never
    : P extends keyof T
      ? T[P]
      : never;
 
function getConfig<P extends ConfigPaths>(path: P): GetValueByPath<Config, P> {
  // implementace
  const keys = path.split(".");
  let result: unknown = config;
  for (const key of keys) {
    result = (result as Record<string, unknown>)[key];
  }
  return result as GetValueByPath<Config, P>;
}
 
const host = getConfig("database.host");         // typ: string
const port = getConfig("database.port");         // typ: number
const username = getConfig("database.credentials.username"); // typ: string

Plné automatické doplňování, plná typová bezpečnost, nulový runtime overhead z typů.

Rekurzivní typy#

TypeScript podporuje rekurzivní definice typů, což ti umožňuje pracovat s libovolně vnořenými strukturami.

Deep Partial#

Vestavěný Partial<T> dělá volitelné pouze vlastnosti na nejvyšší úrovni. Pro vnořené objekty potřebuješ rekurzi:

typescript
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object
    ? T[K] extends Array<infer U>
      ? Array<DeepPartial<U>>
      : DeepPartial<T[K]>
    : T[K];
};
 
interface Settings {
  theme: {
    colors: {
      primary: string;
      secondary: string;
    };
    fonts: {
      heading: string;
      body: string;
    };
  };
  notifications: {
    email: boolean;
    push: boolean;
  };
}
 
type PartialSettings = DeepPartial<Settings>;
// Každá vnořená vlastnost je teď volitelná

Všimni si kontroly pole: bez ní by se pole zpracovávala jako objekty a jejich číselné indexy by se staly volitelnými, což nechceš.

Deep Readonly#

Stejný pattern, jiný modifikátor:

typescript
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? T[K] extends Function
      ? T[K]
      : T[K] extends Array<infer U>
        ? ReadonlyArray<DeepReadonly<U>>
        : DeepReadonly<T[K]>
    : T[K];
};

Kontrola Function je důležitá — bez ní by se na vlastnosti typu funkce aplikovalo readonly na jejich vnitřní strukturu, což nedává smysl.

Typ JSON#

Tady je klasický rekurzivní typ — reprezentace jakékoli platné JSON hodnoty:

typescript
type JsonPrimitive = string | number | boolean | null;
type JsonArray = JsonValue[];
type JsonObject = { [key: string]: JsonValue };
type JsonValue = JsonPrimitive | JsonArray | JsonObject;
 
function parseJson(input: string): JsonValue {
  return JSON.parse(input);
}
 
function isJsonObject(value: JsonValue): value is JsonObject {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

Tohle je mnohem lepší než používat any pro parsování JSON. Říká to konzumentovi „dostaneš zpátky něco, ale musíš to zúžit, než to použiješ." Což je přesně pravda.

Rekurzivní typ pro vnořená menu#

Praktický příklad — typování struktury navigačního menu:

typescript
interface MenuItem {
  label: string;
  href: string;
  icon?: string;
  children?: MenuItem[];
}
 
type Menu = MenuItem[];
 
const navigation: Menu = [
  {
    label: "Products",
    href: "/products",
    children: [
      {
        label: "Software",
        href: "/products/software",
        children: [
          { label: "IDE", href: "/products/software/ide" },
          { label: "CLI Tools", href: "/products/software/cli" },
        ],
      },
      { label: "Hardware", href: "/products/hardware" },
    ],
  },
  { label: "About", href: "/about" },
];

Rekurzivní vlastnost children ti dává nekonečné vnořování s plnou typovou bezpečností.

Reálné patterny#

Dovolte mi sdílet patterny, které jsem skutečně použil v produkčních systémech.

Builder pattern s typy#

Builder pattern se stává výrazně užitečnějším, když typový systém sleduje, co bylo nastaveno:

typescript
type RequiredKeys = "host" | "port" | "database";
 
type BuilderState = {
  [K in RequiredKeys]?: true;
};
 
class DatabaseConfigBuilder<State extends BuilderState = {}> {
  private config: Partial<{
    host: string;
    port: number;
    database: string;
    ssl: boolean;
    poolSize: number;
  }> = {};
 
  setHost(host: string): DatabaseConfigBuilder<State & { host: true }> {
    this.config.host = host;
    return this as unknown as DatabaseConfigBuilder<State & { host: true }>;
  }
 
  setPort(port: number): DatabaseConfigBuilder<State & { port: true }> {
    this.config.port = port;
    return this as unknown as DatabaseConfigBuilder<State & { port: true }>;
  }
 
  setDatabase(db: string): DatabaseConfigBuilder<State & { database: true }> {
    this.config.database = db;
    return this as unknown as DatabaseConfigBuilder<State & { database: true }>;
  }
 
  setSsl(ssl: boolean): DatabaseConfigBuilder<State> {
    this.config.ssl = ssl;
    return this as unknown as DatabaseConfigBuilder<State>;
  }
 
  setPoolSize(size: number): DatabaseConfigBuilder<State> {
    this.config.poolSize = size;
    return this as unknown as DatabaseConfigBuilder<State>;
  }
 
  build(
    this: DatabaseConfigBuilder<{ host: true; port: true; database: true }>
  ): Required<Pick<typeof this.config, "host" | "port" | "database">> &
    Partial<Pick<typeof this.config, "ssl" | "poolSize">> {
    return this.config as ReturnType<typeof this.build>;
  }
}
 
// Tohle se zkompiluje:
const config = new DatabaseConfigBuilder()
  .setHost("localhost")
  .setPort(5432)
  .setDatabase("myapp")
  .setSsl(true)
  .build();
 
// Tohle selže v době kompilace — chybí .setDatabase():
// const bad = new DatabaseConfigBuilder()
//   .setHost("localhost")
//   .setPort(5432)
//   .build(); // Chyba: chybí 'database'

Metoda build() se stane zavolatelnou teprve když jsou nastavena všechna povinná pole. Tohle se zachytí v době kompilace, ne za běhu.

Typově bezpečný Event Emitter#

Tady je event emitter, kde jsou typy payloadů vynucené:

typescript
type EventMap = Record<string, unknown>;
 
class TypedEventEmitter<Events extends EventMap> {
  private listeners: {
    [K in keyof Events]?: Array<(payload: Events[K]) => void>;
  } = {};
 
  on<K extends keyof Events>(
    event: K,
    listener: (payload: Events[K]) => void
  ): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(listener);
  }
 
  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    this.listeners[event]?.forEach((listener) => listener(payload));
  }
 
  off<K extends keyof Events>(
    event: K,
    listener: (payload: Events[K]) => void
  ): void {
    const handlers = this.listeners[event];
    if (handlers) {
      this.listeners[event] = handlers.filter((h) => h !== listener) as typeof handlers;
    }
  }
}
 
// Použití
interface AppEvents {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "notification:new": { message: string; level: "info" | "warning" | "error" };
}
 
const emitter = new TypedEventEmitter<AppEvents>();
 
// Plně typované — IDE automaticky doplňuje názvy eventů a tvary payloadů
emitter.on("user:login", (payload) => {
  console.log(payload.userId);     // string
  console.log(payload.timestamp);  // number
});
 
// Typová chyba: payload neodpovídá
// emitter.emit("user:login", { userId: "123" });
// Chyba: chybí 'timestamp'
 
// Typová chyba: event neexistuje
// emitter.on("user:signup", () => {});
// Chyba: "user:signup" není v AppEvents

Tenhle pattern jsem použil ve třech různých produkčních projektech. Zachytává celé kategorie event-related bugů v době kompilace.

Diskriminované unie s vyčerpávající kontrolou#

Diskriminované unie jsou pravděpodobně nejužitečnější pattern v TypeScriptu. V kombinaci s vyčerpávající kontrolou garantují, že obsloužíš každý případ:

typescript
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      // Tenhle řádek zajišťuje vyčerpávající kontrolu
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

Pokud někdo přidá novou variantu tvaru (řekněme "pentagon"), tahle funkce se nezkompiluje, protože never nemůže být přiřazena hodnota. Kompilátor tě donutí obsloužit každý případ.

Jdu ještě dál s helper funkcí:

typescript
function assertNever(value: never, message?: string): never {
  throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      assertNever(shape, `Unknown shape kind: ${(shape as Shape).kind}`);
  }
}

Typově bezpečné stavové automaty#

Diskriminované unie také krásně modelují stavové automaty:

typescript
type ConnectionState =
  | { status: "disconnected" }
  | { status: "connecting"; attempt: number }
  | { status: "connected"; socket: WebSocket; connectedAt: Date }
  | { status: "error"; error: Error; lastAttempt: Date };
 
type ConnectionAction =
  | { type: "CONNECT" }
  | { type: "CONNECTED"; socket: WebSocket }
  | { type: "DISCONNECT" }
  | { type: "ERROR"; error: Error }
  | { type: "RETRY" };
 
function connectionReducer(
  state: ConnectionState,
  action: ConnectionAction
): ConnectionState {
  switch (state.status) {
    case "disconnected":
      if (action.type === "CONNECT") {
        return { status: "connecting", attempt: 1 };
      }
      return state;
 
    case "connecting":
      if (action.type === "CONNECTED") {
        return {
          status: "connected",
          socket: action.socket,
          connectedAt: new Date(),
        };
      }
      if (action.type === "ERROR") {
        return {
          status: "error",
          error: action.error,
          lastAttempt: new Date(),
        };
      }
      return state;
 
    case "connected":
      if (action.type === "DISCONNECT") {
        state.socket.close();
        return { status: "disconnected" };
      }
      if (action.type === "ERROR") {
        return {
          status: "error",
          error: action.error,
          lastAttempt: new Date(),
        };
      }
      return state;
 
    case "error":
      if (action.type === "RETRY") {
        return { status: "connecting", attempt: 1 };
      }
      if (action.type === "DISCONNECT") {
        return { status: "disconnected" };
      }
      return state;
 
    default:
      assertNever(state, "Unknown connection state");
  }
}

Každý stav má pouze vlastnosti, které dávají smysl pro daný stav. Nemůžeš přistoupit k socket, když jsi odpojený. Nemůžeš přistoupit k error, když jsi připojený. Typový systém vynucuje omezení stavového automatu.

Branded typy pro doménovou bezpečnost#

Ještě jeden pattern, který považuji za nezbytný — použití branded typů k prevenci záměny hodnot, které náhodou sdílejí stejný podkladový typ:

typescript
type Brand<T, B extends string> = T & { readonly __brand: B };
 
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
 
function createUserId(id: string): UserId {
  return id as UserId;
}
 
function createOrderId(id: string): OrderId {
  return id as OrderId;
}
 
function getUser(id: UserId): Promise<User> {
  // ...
}
 
function getOrder(id: OrderId): Promise<Order> {
  // ...
}
 
const userId = createUserId("user_123");
const orderId = createOrderId("order_456");
 
getUser(userId);   // OK
// getUser(orderId); // Chyba! OrderId nelze přiřadit k UserId

Jak UserId, tak OrderId jsou za běhu řetězce. Ale v době kompilace jsou to odlišné typy. Doslova nemůžeš předat order ID tam, kde se očekává user ID. Tohle zachytilo reálné bugy v každém projektu, kde jsem to použil.

Časté nástrahy#

Pokročilé typy jsou mocné, ale přichází s pastmi. Tady je to, co jsem se naučil těžkým způsobem.

Limity kruhových referencí#

TypeScript má limit hloubky rekurze pro typy (aktuálně kolem 50 úrovní, i když se to liší). Pokud jdeš příliš hluboko, dostaneš obávanou chybu „Type instantiation is excessively deep and possibly infinite".

typescript
// Tohle narazí na limit rekurze u hluboce vnořených objektů
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
 
// Oprava: Přidej čítač hloubky
type DeepPartialSafe<T, Depth extends number[] = []> =
  Depth["length"] extends 10
    ? T
    : {
        [K in keyof T]?: T[K] extends object
          ? DeepPartialSafe<T[K], [...Depth, 0]>
          : T[K];
      };

Trik s čítačem hloubky používá tuple, který roste s každým rekurzivním krokem. Když dosáhne tvého limitu, rekurze se zastaví.

Dopady na výkon#

Složité typy mohou výrazně zpomalit tvůj editor a build časy. Viděl jsem projekty, kde jeden přehnaně chytrý typ přidal 3 sekundy ke zpětné vazbě při každém stisku klávesy.

Varovné signály:

  • IDE trvá více než 2 sekundy zobrazit autocomplete
  • tsc --noEmit trvá výrazně déle po přidání typu
  • Vidíš chyby „Type instantiation is excessively deep"
typescript
// Tohle je moc chytré — generuje kombinatorickou explozi
type AllCombinations<T extends string[]> = T extends [
  infer First extends string,
  ...infer Rest extends string[]
]
  ? `${First}${AllCombinations<Rest>}` | AllCombinations<Rest>
  : "";
 
// Nedělej tohle s více než 5-6 elementy

Kdy pokročilé typy NEPOUŽÍVAT#

Tohle je možná nejdůležitější sekce. Pokročilé typy by se měly používat, když zabraňují bugům nebo zlepšují vývojářský zážitek. Neměly by se používat k předvádění.

Nepoužívej je, když:

  • Jednoduchý Record<string, unknown> by stačil
  • Typ se používá jen na jednom místě a konkrétní typ by byl jasnější
  • Trávíš více času debugováním typu, než kolik by ušetřil
  • Tvůj tým to nedokáže přečíst ani udržovat
  • Runtime kontrola by byla vhodnější

Používej je, když:

  • Máš pattern, který se opakuje napříč mnoha typy (mapované typy)
  • Návratový typ funkce závisí na jejím vstupu (podmíněné typy)
  • Stavíš API knihovny, které musí být samo-dokumentující
  • Chceš učinit nelegální stavy nereprezentovatelné
  • Jsi unavený ze stejné kategorie bugů, která se objevuje v code review
typescript
// Přehnané — nedělej tohle pro jednoduchý config
type Config = DeepReadonly<
  DeepRequired<
    Merge<DefaultConfig, Partial<UserConfig>>
  >
>;
 
// Prostě udělej tohle
interface Config {
  readonly host: string;
  readonly port: number;
  readonly debug: boolean;
}

Mé pravidlo: pokud vysvětlení typu trvá déle než vysvětlení bugu, kterému brání, zjednoduš ho.

Debugování složitých typů#

Když složitý typ nefunguje, používám tenhle helper k „vidění" toho, co TypeScript vyřešil:

typescript
// Rozbalí typ pro inspekci v IDE hover tooltipech
type Prettify<T> = {
  [K in keyof T]: T[K];
} & {};
 
// Použij k debugování
type Problematic = SomeComplexType<Input>;
type Debug = Prettify<Problematic>;
// Teď najeď myší nad Debug v IDE a uvidíš vyřešený typ

Trik & {} donutí TypeScript dychtivě vyhodnotit typ místo zobrazení typového aliasu. Ušetřil mi hodiny zmatků.

Další technika — izoluj a testuj postupně:

typescript
// Místo debugování všeho najednou:
type Final = StepThree<StepTwo<StepOne<Input>>>;
 
// Rozděl to:
type AfterStep1 = StepOne<Input>;       // najeď myší pro kontrolu
type AfterStep2 = StepTwo<AfterStep1>;  // najeď myší pro kontrolu
type AfterStep3 = StepThree<AfterStep2>; // najeď myší pro kontrolu

Shrnutí#

  • Podmíněné typy (T extends U ? X : Y) jsou if/else pro typy. Pozor na distributivní chování s uniony.
  • infer je pattern matching — používej k extrakci typů ze struktur jako signatury funkcí, promises a pole.
  • Mapované typy ({ [K in keyof T]: ... }) iterují přes vlastnosti. Kombinuj s as pro přemapování a filtrování klíčů.
  • Šablonové literálové typy manipulují s řetězci na úrovni typů. V kombinaci s mapovanými typy jsou neuvěřitelně mocné pro API design.
  • Rekurzivní typy zpracovávají vnořené struktury, ale potřebují limity hloubky, aby se vyhnuly explozím kompilátoru.
  • Diskriminované unie s vyčerpávající kontrolou jsou nejhodnotnější pattern v TypeScriptu. Používej je všude.
  • Branded typy zabraňují záměně hodnot, které sdílejí stejný podkladový typ. Jednoduché na implementaci, vysoký dopad.
  • Nepřehánějte typy. Pokud je typ těžší na pochopení než bug, kterému brání, zjednoduš ho. Cíl je učinit tvůj codebase bezpečnějším, ne vyhrát soutěž v typovém golfu.

Typový systém TypeScriptu je Turingovsky úplný, což znamená, že s ním můžeš udělat téměř cokoli. Umění je vědět, kdy bys měl.

Související články