Zaawansowane typy TypeScript: Conditional, Mapped i Template Literal Types w praktyce
Głębokie zanurzenie w najpotężniejsze funkcje systemu typów TypeScript — conditional types, mapped types, słowo kluczowe infer i template literal types. Realne wzorce, które faktycznie wykorzystasz.
Większość programistów TypeScript zatrzymuje się w tym samym miejscu. Znają interfejsy, generyki, union types. Potrafią otypować komponent React. Potrafią sprawić, żeby czerwone podkreślenia zniknęły. Ale traktują system typów jako zło konieczne — coś, co istnieje po to, żeby zapobiegać błędom, a nie coś, co aktywnie pomaga projektować lepsze oprogramowanie.
Utknąłem na tym plateau na jakieś dwa lata. Wyciągnęło mnie z tego zrozumienie, że system typów TypeScript to sam w sobie język programowania. Ma instrukcje warunkowe, pętle, pattern matching, rekurencję i manipulację stringami. Kiedy to zinternalizujesz, zmienia się wszystko. Przestajesz walczyć z kompilatorem i zaczynasz z nim współpracować.
Ten post opisuje funkcje na poziomie typów, których regularnie używam w kodzie produkcyjnym. Żadnych ćwiczeń akademickich — realne wzorce, które uratowały mnie przed realnymi bugami.
Myślenie w kategoriach programowania na poziomie typów#
Zanim przejdziemy do składni, chcę zmienić sposób, w jaki myślisz o typach.
W programowaniu na poziomie wartości piszesz funkcje, które transformują dane w czasie wykonania:
function extractIds(users: User[]): number[] {
return users.map(u => u.id);
}W programowaniu na poziomie typów piszesz typy, które transformują inne typy w czasie kompilacji:
type ExtractIds<T extends { id: unknown }[]> = T[number]["id"];Model mentalny jest ten sam — wejście, transformacja, wyjście. Różnica polega na tym, że kod na poziomie typów wykonuje się podczas kompilacji, nie w runtime. Nie produkuje żadnego JavaScriptu. Jego jedynym celem jest uczynienie niemożliwych stanów niereprezentowalnymi.
Jeśli kiedykolwiek napisałeś funkcję, w której trzy z czterech parametrów mają sens tylko w pewnych kombinacjach, albo w której typ zwracany zależy od tego, co przekażesz — to już potrzebowałeś programowania na poziomie typów. Po prostu mogłeś nie wiedzieć, że TypeScript potrafi to wyrazić.
Conditional Types#
Conditional types to if/else systemu typów. Składnia wygląda jak operator trójargumentowy:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
type C = IsString<string>; // trueSłowo kluczowe extends nie oznacza tu dziedziczenia. Oznacza „jest przypisywalny do". Pomyśl o tym jak o pytaniu: „czy T pasuje do kształtu string?"
Budowanie własnych utility types#
Odtwórzmy kilka wbudowanych utility types TypeScript, żeby zrozumieć, jak działają.
NonNullable — usuwa null i undefined z unii:
type MyNonNullable<T> = T extends null | undefined ? never : T;
type Test = MyNonNullable<string | null | undefined>;
// Result: stringTyp never to „zbiór pusty" — usuwanie czegoś z unii. Kiedy gałąź conditional type rozwiązuje się do never, ten element unii po prostu znika.
Extract — zachowuje tylko elementy unii pasujące do ograniczenia:
type MyExtract<T, U> = T extends U ? T : never;
type Numbers = Extract<string | number | boolean, number | boolean>;
// Result: number | booleanExclude — odwrotność, usuwa pasujące elementy:
type MyExclude<T, U> = T extends U ? never : T;
type WithoutStrings = Exclude<string | number | boolean, string>;
// Result: number | booleanDistributive conditional types#
Tu robi się ciekawie i tu większość ludzi się gubi. Kiedy przekazujesz union type do conditional type, TypeScript dystrybuuje warunek na każdy element unii osobno.
type ToArray<T> = T extends unknown ? T[] : never;
type Result = ToArray<string | number>;
// You might expect: (string | number)[]
// Actual result: string[] | number[]TypeScript nie ewaluuje (string | number) extends unknown. Zamiast tego ewaluuje string extends unknown i number extends unknown osobno, a potem łączy wyniki w unię.
Dlatego Extract i Exclude działają tak, jak działają. Dystrybucja następuje automatycznie, kiedy T jest „nagim" parametrem typowym (nieopakowanym w nic).
Jeśli chcesz zapobiec dystrybucji, opakuj obie strony w krotkę:
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type Result = ToArrayNonDist<string | number>;
// Result: (string | number)[]Nadziałem się na to więcej razy, niż chciałbym przyznać. Jeśli twój conditional type daje nieoczekiwane wyniki z wejściami typu union, dystrybucja jest prawie zawsze powodem.
Przykład praktyczny: obsługa odpowiedzi API#
Oto wzorzec, którego używam przy obsłudze odpowiedzi API, które mogą być sukcesem lub błędem:
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>;
// Result: { id: number; name: string }
type UserError = ExtractError<UserResponse>;
// Result: stringTo jest czystsze niż pisanie ręcznych type guardów dla każdego kształtu odpowiedzi.
Słowo kluczowe infer#
infer to pattern matching dla typów. Pozwala zadeklarować zmienną typową wewnątrz conditional type, którą TypeScript sam wydedukuje.
Pomyśl o tym jak: „Wiem, że ten typ ma pewien kształt. Wyciągnij z niego część, na której mi zależy."
Wyciąganie typów zwracanych#
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // booleanTypeScript widzi wzorzec (...args) => something, dopasowuje twoją funkcję i przypisuje typ zwracany do R.
Wyciąganie typów parametrów#
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]Rozpakowywanie Promise#
Tego używam cały czas przy pracy z funkcjami asynchronicznymi:
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)Dla głęboko zagnieżdżonych promisów:
type DeepUnpackPromise<T> = T extends Promise<infer U>
? DeepUnpackPromise<U>
: T;
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Result: numberWyciąganie typów elementów tablicy#
type ElementOf<T> = T extends (infer E)[] ? E : never;
type Items = ElementOf<string[]>; // string
type Mixed = ElementOf<(string | number)[]>; // string | numberWielokrotne infer w jednym typie#
Możesz użyć infer wielokrotnie, żeby wyciągnąć różne części:
type FirstAndLast<T extends unknown[]> =
T extends [infer First, ...unknown[], infer Last]
? { first: First; last: Last }
: never;
type Result = FirstAndLast<[1, 2, 3, 4]>;
// Result: { first: 1; last: 4 }Przykład z życia: type-safe ekstrakcja zdarzeń#
Oto wzorzec, którego używam w systemach opartych na zdarzeniach:
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];
// Extract all user-related events
type UserEvents = Extract<EventName, `user:${string}`>;
// Result: "user:login" | "user:logout"
// Get the payload for user events only
type UserEventPayloads = EventPayload<UserEvents>;
// Result: { userId: string; timestamp: number } | { userId: string }Mapped Types#
Mapped types pozwalają tworzyć nowe typy obiektowe, transformując każdą właściwość istniejącego typu. To odpowiednik map() w systemie typów.
Podstawy#
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
type Partial<T> = {
[K in keyof T]?: T[K];
};keyof T daje ci unię wszystkich kluczy właściwości. [K in ...] iteruje po każdym z nich. T[K] to typ danej właściwości.
Budowanie utility types od zera#
Required — uczynienie wszystkich właściwości obowiązkowymi:
type MyRequired<T> = {
[K in keyof T]-?: T[K];
};Składnia -? usuwa modyfikator opcjonalności. Analogicznie, -readonly usuwa readonly.
Pick — wybranie konkretnych właściwości:
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 — usunięcie konkretnych właściwości:
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 — stworzenie typu obiektowego z konkretnymi kluczami i typami wartości:
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 }Remapowanie kluczy z as#
TypeScript 4.1 wprowadził remapowanie kluczy, które pozwala transformować nazwy właściwości:
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;
// }Możesz też filtrować właściwości, remapując do 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 }To jest niesamowicie potężne. Jednocześnie iterujesz po właściwościach, filtrujesz je i transformujesz ich nazwy — wszystko na poziomie typów.
Przykład praktyczny: typy walidacji formularzy#
Używałem tego wzorca w kilku bibliotekach do obsługi formularzy:
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;
};
// Now your form state is fully typed based on the fields
type MyFormState = FormState<FormFields>;Dodaj pole do FormFields, a każdy powiązany typ zaktualizuje się automatycznie. Usuń pole, a kompilator złapie każde odwołanie. To jest dokładnie to, co zapobiega całym kategoriom bugów.
Template Literal Types#
Template literal types pozwalają manipulować stringami na poziomie typów. Używają tej samej składni z backtickami co template literals w JavaScript, ale dla typów.
Podstawowa manipulacja stringami#
type Greeting = `Hello, ${string}`;
const a: Greeting = "Hello, world"; // OK
const b: Greeting = "Hello, TypeScript"; // OK
// const c: Greeting = "Hi, world"; // Error!TypeScript udostępnia też wbudowane typy do manipulacji stringami:
type Upper = Uppercase<"hello">; // "HELLO"
type Lower = Lowercase<"HELLO">; // "hello"
type Cap = Capitalize<"hello">; // "Hello"
type Uncap = Uncapitalize<"Hello">; // "hello"Wzorce nazw zdarzeń#
Tu template literal types naprawdę błyszczą. Oto wzorzec, którego używam dla type-safe systemów zdarzeń:
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"Dziewięć zdarzeń z dwóch parametrów typowych. I wszystkie są type-safe.
Wzorce CSS-w-TypeScript#
Template literal types mogą wymuszać wzorce CSS na poziomie typów:
type CSSUnit = "px" | "rem" | "em" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;
function setWidth(value: CSSValue): void {
// implementation
}
setWidth("100px"); // OK
setWidth("2.5rem"); // OK
setWidth("50%"); // OK
// setWidth("100"); // Error: not a valid CSS value
// setWidth("big"); // ErrorParsowanie stringów z template literal types#
Możesz użyć template literal types z infer do parsowania stringów:
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">;
// Result: "userId" | "postId"To jest fundament tego, jak działają type-safe biblioteki routingowe takie jak tRPC. String /users/:userId/posts/:postId jest parsowany na poziomie typów, żeby wyekstrahować nazwy parametrów.
Łączenie template literal types z mapped types#
Tu sprawa robi się naprawdę potężna:
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 prostego interfejsu konfiguracyjnego dostajesz w pełni otypowane handlery zdarzeń z poprawnymi typami parametrów. To jest ten rodzaj kodu na poziomie typów, który faktycznie oszczędza czas przy developmencie.
Zaawansowane: typy ścieżek z notacją kropkową#
Oto bardziej zaawansowany wzorzec — generowanie wszystkich możliwych ścieżek z notacją kropkową przez zagnieżdżony obiekt:
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"Teraz możesz napisać funkcję getConfig, która autouzupełnia każdą możliwą ścieżkę i zwraca poprawny typ dla każdej 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> {
// implementation
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"); // type: string
const port = getConfig("database.port"); // type: number
const username = getConfig("database.credentials.username"); // type: stringPełne autouzupełnianie, pełne bezpieczeństwo typów, zerowy narzut w runtime od typów.
Typy rekurencyjne#
TypeScript wspiera rekurencyjne definicje typów, co pozwala obsługiwać dowolnie zagnieżdżone struktury.
Deep Partial#
Wbudowany Partial<T> sprawia, że tylko właściwości najwyższego poziomu są opcjonalne. Dla zagnieżdżonych obiektów potrzebujesz rekurencji:
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>;
// Every nested property is now optionalZwróć uwagę na sprawdzenie tablicy: bez niego tablice byłyby traktowane jako obiekty, a ich indeksy numeryczne stałyby się opcjonalne, co nie jest tym, czego chcesz.
Deep Readonly#
Ten sam wzorzec, inny modyfikator:
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];
};Sprawdzenie Function jest istotne — bez niego właściwości typu funkcja miałyby readonly zastosowane do ich wewnętrznej struktury, co nie ma sensu.
Typ JSON#
Oto klasyczny typ rekurencyjny — reprezentujący dowolną poprawną wartość JSON:
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);
}To jest dużo lepsze niż używanie any do parsowania JSON. Mówi konsumentowi: „dostaniesz coś z powrotem, ale musisz to zawęzić, zanim tego użyjesz." Co jest dokładnie prawdą.
Typ rekurencyjny dla zagnieżdżonych menu#
Praktyczny przykład — typowanie struktury menu nawigacyjnego:
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" },
];Rekurencyjna właściwość children daje ci nieskończone zagnieżdżanie z pełnym bezpieczeństwem typów.
Wzorce z prawdziwego życia#
Podzielę się wzorcami, których faktycznie używałem w systemach produkcyjnych.
Wzorzec Builder z typami#
Wzorzec builder staje się znacznie bardziej użyteczny, kiedy system typów śledzi, co zostało ustawione:
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>;
}
}
// This compiles:
const config = new DatabaseConfigBuilder()
.setHost("localhost")
.setPort(5432)
.setDatabase("myapp")
.setSsl(true)
.build();
// This fails at compile time — missing .setDatabase():
// const bad = new DatabaseConfigBuilder()
// .setHost("localhost")
// .setPort(5432)
// .build(); // Error: 'database' is missingMetoda build() staje się wywoływalna dopiero, gdy wszystkie wymagane pola zostały ustawione. To jest wyłapywane w czasie kompilacji, nie w runtime.
Type-safe event emitter#
Oto event emitter, w którym typy payloadów są wymuszane:
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;
}
}
}
// Usage
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>();
// Fully typed — IDE autocompletes event names and payload shapes
emitter.on("user:login", (payload) => {
console.log(payload.userId); // string
console.log(payload.timestamp); // number
});
// Type error: payload doesn't match
// emitter.emit("user:login", { userId: "123" });
// Error: missing 'timestamp'
// Type error: event doesn't exist
// emitter.on("user:signup", () => {});
// Error: "user:signup" is not in AppEventsUżywałem tego wzorca w trzech różnych projektach produkcyjnych. Wyłapuje on całe kategorie bugów związanych ze zdarzeniami w czasie kompilacji.
Discriminated unions z exhaustive checking#
Discriminated unions to prawdopodobnie pojedynczy najużyteczniejszy wzorzec w TypeScript. W połączeniu z exhaustive checking gwarantują, że obsłużysz każdy przypadek:
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:
// This line ensures exhaustive checking
const _exhaustive: never = shape;
return _exhaustive;
}
}Jeśli ktoś doda nowy wariant kształtu (powiedzmy "pentagon"), ta funkcja nie skompiluje się, ponieważ do never nie można przypisać wartości. Kompilator zmusza cię do obsłużenia każdego przypadku.
Rozwijam to dalej z funkcją pomocniczą:
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}`);
}
}Type-safe maszyny stanów#
Discriminated unions modelują też pięknie maszyny stanów:
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żdy stan ma tylko właściwości, które mają sens dla tego stanu. Nie możesz odwołać się do socket, kiedy jesteś rozłączony. Nie możesz odwołać się do error, kiedy jesteś połączony. System typów wymusza ograniczenia maszyny stanów.
Branded types dla bezpieczeństwa domeny#
Jeszcze jeden wzorzec, który uważam za niezbędny — użycie branded types, żeby zapobiec mieszaniu wartości, które mają ten sam typ bazowy:
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); // Error! OrderId is not assignable to UserIdZarówno UserId, jak i OrderId to stringi w runtime. Ale w czasie kompilacji to odrębne typy. Dosłownie nie możesz przekazać ID zamówienia tam, gdzie oczekiwane jest ID użytkownika. To wyłapało realne bugi w każdym projekcie, w którym tego używałem.
Częste pułapki#
Zaawansowane typy są potężne, ale mają swoje pułapki. Oto czego nauczyłem się na własnej skórze.
Limity referencji cyklicznych#
TypeScript ma limit głębokości rekurencji dla typów (obecnie około 50 poziomów, choć to się różni). Jeśli pójdziesz za głęboko, dostaniesz przerażający błąd „Type instantiation is excessively deep and possibly infinite".
// This will hit the recursion limit for deeply nested objects
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
// Fix: Add a depth counter
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 z licznikiem głębokości wykorzystuje krotkę, która rośnie z każdym krokiem rekurencji. Kiedy osiągnie twój limit, rekurencja się zatrzymuje.
Wpływ na wydajność#
Złożone typy mogą znacząco spowolnić twój edytor i czasy budowania. Widziałem projekty, w których jeden zbyt sprytny typ dodawał 3 sekundy do feedbacku po każdym naciśnięciu klawisza.
Sygnały ostrzegawcze:
- Twoje IDE potrzebuje więcej niż 2 sekundy, żeby pokazać autouzupełnianie
tsc --noEmittrwa zauważalnie dłużej po dodaniu typu- Widzisz błędy „Type instantiation is excessively deep"
// This is too clever — it generates a combinatorial explosion
type AllCombinations<T extends string[]> = T extends [
infer First extends string,
...infer Rest extends string[]
]
? `${First}${AllCombinations<Rest>}` | AllCombinations<Rest>
: "";
// Don't do this with more than 5-6 elementsKiedy NIE używać zaawansowanych typów#
To jest być może najważniejsza sekcja. Zaawansowane typy powinny być używane, kiedy zapobiegają bugom lub poprawiają developer experience. Nie powinny być używane do popisywania się.
Nie używaj ich, kiedy:
- Proste
Record<string, unknown>wystarczyłoby - Typ jest używany tylko w jednym miejscu i konkretny typ byłby czytelniejszy
- Spędzasz więcej czasu na debugowaniu typu niż on by zaoszczędził
- Twój zespół nie potrafi go przeczytać ani utrzymać
- Sprawdzenie w runtime byłoby bardziej odpowiednie
Używaj ich, kiedy:
- Masz wzorzec, który powtarza się w wielu typach (mapped types)
- Typ zwracany funkcji zależy od jej wejścia (conditional types)
- Budujesz API biblioteki, które powinno być samodokumentujące się
- Chcesz uczynić nielegalne stany niereprezentowalnymi
- Masz dość tej samej kategorii bugów pojawiającej się na code review
// Over-engineered — don't do this for a simple config
type Config = DeepReadonly<
DeepRequired<
Merge<DefaultConfig, Partial<UserConfig>>
>
>;
// Just do this
interface Config {
readonly host: string;
readonly port: number;
readonly debug: boolean;
}Moja zasada: jeśli wyjaśnienie typu trwa dłużej niż wyjaśnienie buga, któremu zapobiega — uprość go.
Debugowanie złożonych typów#
Kiedy złożony typ nie działa, używam tego helpera, żeby „zobaczyć", co TypeScript rozwiązał:
// Expands a type for inspection in IDE hover tooltips
type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
// Use it to debug
type Problematic = SomeComplexType<Input>;
type Debug = Prettify<Problematic>;
// Now hover over Debug in your IDE to see the resolved typeTrik z & {} zmusza TypeScript do zachłannego ewaluowania typu zamiast pokazywania aliasu. Zaoszczędził mi godzin frustracji.
Kolejna technika — izoluj i testuj inkrementalnie:
// Instead of debugging this all at once:
type Final = StepThree<StepTwo<StepOne<Input>>>;
// Break it apart:
type AfterStep1 = StepOne<Input>; // hover to check
type AfterStep2 = StepTwo<AfterStep1>; // hover to check
type AfterStep3 = StepThree<AfterStep2>; // hover to checkPodsumowanie#
- Conditional types (
T extends U ? X : Y) to if/else dla typów. Uważaj na dystrybutywne zachowanie z uniami. inferto pattern matching — używaj go do wyciągania typów ze struktur takich jak sygnatury funkcji, promisy i tablice.- Mapped types (
{ [K in keyof T]: ... }) iterują po właściwościach. Łącz zasdla remapowania i filtrowania kluczy. - Template literal types manipulują stringami na poziomie typów. W połączeniu z mapped types są niesamowicie potężne przy projektowaniu API.
- Typy rekurencyjne obsługują zagnieżdżone struktury, ale potrzebują limitów głębokości, żeby uniknąć eksplozji kompilatora.
- Discriminated unions z exhaustive checking to pojedynczy wzorzec o najwyższej wartości w TypeScript. Używaj ich wszędzie.
- Branded types zapobiegają mieszaniu wartości, które mają ten sam typ bazowy. Proste w implementacji, duży wpływ.
- Nie przerabiaj typów. Jeśli typ jest trudniejszy do zrozumienia niż bug, któremu zapobiega, uprość go. Celem jest uczynienie twojego kodu bezpieczniejszym, nie wygranie zawodów w type golfie.
System typów TypeScript jest Turing-zupełny, co oznacza, że możesz zrobić z nim prawie wszystko. Sztuka polega na wiedzy, kiedy powinieneś.