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.
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 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?"
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 | booleanTu 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.
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.
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."
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.
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]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: numbertype ElementOf<T> = T extends (infer E)[] ? E : never;
type Items = ElementOf<string[]>; // string
type Mixed = ElementOf<(string | number)[]>; // string | numberinfer 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 }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 pozwalają tworzyć nowe typy obiektowe, transformując każdą właściwość istniejącego typu. To odpowiednik map() w systemie typów.
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.
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 }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.
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 pozwalają manipulować stringami na poziomie typów. Używają tej samej składni z backtickami co template literals w JavaScript, ale dla typów.
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"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.
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"); // ErrorMoż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.
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.
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.
TypeScript wspiera rekurencyjne definicje typów, co pozwala obsługiwać dowolnie zagnieżdżone struktury.
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.
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.
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ą.
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.
Podzielę się wzorcami, których faktycznie używałem w systemach produkcyjnych.
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.
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 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}`);
}
}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.
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.
Zaawansowane typy są potężne, ale mają swoje pułapki. Oto czego nauczyłem się na własnej skórze.
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.
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:
tsc --noEmit trwa zauważalnie dłużej po dodaniu typu// 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 elementsTo 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:
Record<string, unknown> wystarczyłobyUżywaj ich, kiedy:
// 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.
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 checkT extends U ? X : Y) to if/else dla typów. Uważaj na dystrybutywne zachowanie z uniami.infer to pattern matching — używaj go do wyciągania typów ze struktur takich jak sygnatury funkcji, promisy i tablice.{ [K in keyof T]: ... }) iterują po właściwościach. Łącz z as dla remapowania i filtrowania kluczy.System typów TypeScript jest Turing-zupełny, co oznacza, że możesz zrobić z nim prawie wszystko. Sztuka polega na wiedzy, kiedy powinieneś.