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š.
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:
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:
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:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
type C = IsString<string>; // trueKlíč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:
type MyNonNullable<T> = T extends null | undefined ? never : T;
type Test = MyNonNullable<string | null | undefined>;
// Výsledek: stringTyp 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í:
type MyExtract<T, U> = T extends U ? T : never;
type Numbers = Extract<string | number | boolean, number | boolean>;
// Výsledek: number | booleanExclude — opak, odstraní odpovídající členy:
type MyExclude<T, U> = T extends U ? never : T;
type WithoutStrings = Exclude<string | number | boolean, string>;
// Výsledek: number | booleanDistributivní 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ášť.
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:
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:
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: stringTohle 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ů#
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // booleanTypeScript vidí pattern (...args) => něco, porovná tvou funkci s ním a přiřadí návratový typ do R.
Extrakce typů parametrů#
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:
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:
type DeepUnpackPromise<T> = T extends Promise<infer U>
? DeepUnpackPromise<U>
: T;
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Výsledek: numberExtrakce typů prvků pole#
type ElementOf<T> = T extends (infer E)[] ? E : never;
type Items = ElementOf<string[]>; // string
type Mixed = ElementOf<(string | number)[]>; // string | numberVícenásobné infer v jednom typu#
Můžeš použít infer vícekrát k extrakci různých částí:
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:
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#
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:
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:
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:
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:
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í:
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:
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:
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#
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:
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:
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ů:
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"); // ChybaParsování řetězců se šablonovými literály#
Šablonové literálové typy můžeš použít s infer k parsování řetězců:
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:
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:
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:
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: stringPlné 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:
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:
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:
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:
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:
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é:
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 AppEventsTenhle 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:
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í:
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:
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:
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 UserIdJak 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".
// 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 --noEmittrvá výrazně déle po přidání typu- Vidíš chyby „Type instantiation is excessively deep"
// 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 elementyKdy 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
// 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:
// 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ý typTrik & {} donutí TypeScript dychtivě vyhodnotit typ místo zobrazení typového aliasu. Ušetřil mi hodiny zmatků.
Další technika — izoluj a testuj postupně:
// 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 kontroluShrnutí#
- Podmíněné typy (
T extends U ? X : Y) jsou if/else pro typy. Pozor na distributivní chování s uniony. inferje 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 saspro 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.