Просунуті типи TypeScript: Conditional, Mapped та Template Literal магія
Глибоке занурення в найпотужніші можливості системи типів TypeScript — conditional types, mapped types, ключове слово infer та template literal types. Реальні патерни, які ти справді використовуватимеш.
Більшість TypeScript-розробників досягають плато в одному й тому ж місці. Вони знають інтерфейси, дженерики, union-типи. Можуть типізувати React-компонент. Можуть прибрати червоні підкреслення. Але ставляться до системи типів як до неминучого зла — чогось, що існує для запобігання багам, а не як до інструменту, що активно допомагає проєктувати краще програмне забезпечення.
Я стояв на цьому плато приблизно два роки. Мене витягло звідти усвідомлення того, що система типів TypeScript — це мова програмування сама по собі. Вона має умови, цикли, патерн-матчинг, рекурсію та маніпуляцію рядками. Як тільки ти це інтерналізуєш, все змінюється. Ти перестаєш воювати з компілятором і починаєш з ним співпрацювати.
Цей пост охоплює можливості рівня типів, які я постійно використовую в продакшн-коді. Не академічні вправи — реальні патерни, які врятували мене від реальних багів.
Мислення на рівні типів#
Перш ніж занурюватись у синтаксис, хочу переосмислити те, як ти думаєш про типи.
В програмуванні на рівні значень ти пишеш функції, які трансформують дані під час виконання:
function extractIds(users: User[]): number[] {
return users.map(u => u.id);
}В програмуванні на рівні типів ти пишеш типи, що трансформують інші типи під час компіляції:
type ExtractIds<T extends { id: unknown }[]> = T[number]["id"];Ментальна модель та сама — вхід, трансформація, вихід. Різниця в тому, що код на рівні типів виконується під час компіляції, а не під час виконання. Він виробляє нуль JavaScript. Його єдине призначення — зробити неможливі стани непредставимими.
Якщо ти коли-небудь писав функцію, де три з чотирьох параметрів мають сенс лише в певних комбінаціях, або де тип повернення залежить від того, що ти передаєш, — ти вже потребував програмування на рівні типів. Просто, можливо, не знав, що TypeScript це може виразити.
Conditional Types#
Conditional types — це if/else системи типів. Синтаксис виглядає як тернарний оператор:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
type C = IsString<string>; // trueКлючове слово extends тут не означає наслідування. Воно означає «є присвоюваним до». Думай про це як «чи вписується T у форму string?»
Будуємо власні утилітні типи#
Давай перебудуємо деякі вбудовані утилітні типи TypeScript, щоб зрозуміти, як вони працюють.
NonNullable — видаляє null та undefined з union:
type MyNonNullable<T> = T extends null | undefined ? never : T;
type Test = MyNonNullable<string | null | undefined>;
// Result: stringТип never — це «порожня множина» — видалення чогось із union. Коли гілка conditional type розв'язується до never, цей член union просто зникає.
Extract — залишає тільки ті члени union, що відповідають обмеженню:
type MyExtract<T, U> = T extends U ? T : never;
type Numbers = Extract<string | number | boolean, number | boolean>;
// Result: number | booleanExclude — протилежність, видаляє відповідні члени:
type MyExclude<T, U> = T extends U ? never : T;
type WithoutStrings = Exclude<string | number | boolean, string>;
// Result: number | booleanДистрибутивні Conditional Types#
Ось де стає цікаво, і де більшість людей плутається. Коли ти передаєш union type до conditional type, TypeScript розподіляє умову по кожному члену union окремо.
type ToArray<T> = T extends unknown ? T[] : never;
type Result = ToArray<string | number>;
// You might expect: (string | number)[]
// Actual result: string[] | number[]TypeScript не обчислює (string | number) extends unknown. Натомість він обчислює string extends unknown та number extends unknown окремо, а потім об'єднує результати.
Саме тому Extract та Exclude працюють так, як працюють. Розподіл відбувається автоматично, коли T є «голим» параметром типу (не загорнутим у щось).
Якщо ти хочеш запобігти розподілу, заверни обидві сторони в кортеж:
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type Result = ToArrayNonDist<string | number>;
// Result: (string | number)[]Мене це підловило більше разів, ніж я хотів би зізнатися. Якщо твій conditional type дає несподівані результати з union-вхідними даними, причиною майже завжди є дистрибуція.
Практичний приклад: обробник API-відповідей#
Ось патерн, який я використовую при роботі з API-відповідями, що можуть бути успішними або з помилкою:
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: stringЦе чистіше, ніж писати ручні type guards для кожної форми відповіді.
Ключове слово infer#
infer — це патерн-матчинг для типів. Воно дозволяє оголосити змінну типу всередині conditional type, яку TypeScript визначить за тебе.
Думай про це як: «Я знаю, що цей тип має певну форму. Витягни ту частину, яка мене цікавить.»
Витягнення типів повернення#
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // booleanTypeScript бачить патерн (...args) => something, зіставляє твою функцію з ним і присвоює тип повернення до 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]Розгортання Promise#
Цим я користуюся постійно при роботі з асинхронними функціями:
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)Для глибоко вкладених promise:
type DeepUnpackPromise<T> = T extends Promise<infer U>
? DeepUnpackPromise<U>
: T;
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Result: numberВитягнення типів елементів масиву#
type ElementOf<T> = T extends (infer E)[] ? E : never;
type Items = ElementOf<string[]>; // string
type Mixed = ElementOf<(string | number)[]>; // string | numberКілька infer в одному типі#
Можна використовувати infer кілька разів для витягнення різних частин:
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 }Реальний приклад: типобезпечне витягнення подій#
Ось патерн, який я використовую в подієво-орієнтованих системах:
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 дозволяють створювати нові об'єктні типи, трансформуючи кожну властивість існуючого типу. Вони — map() системи типів.
Основи#
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
type Partial<T> = {
[K in keyof T]?: T[K];
};keyof T дає union всіх ключів властивостей. [K in ...] ітерує по кожному. T[K] — це тип цієї властивості.
Будуємо утилітні типи з нуля#
Required — робить усі властивості обов'язковими:
type MyRequired<T> = {
[K in keyof T]-?: T[K];
};Синтаксис -? видаляє модифікатор optional. Аналогічно, -readonly видаляє readonly.
Pick — вибирає конкретні властивості:
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 — видаляє конкретні властивості:
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 — створює об'єктний тип з конкретними ключами та типом значень:
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 представив ремапінг ключів, який дозволяє трансформувати імена властивостей:
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;
// }Також можна фільтрувати властивості, ремапившись до 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 }Це неймовірно потужно. Ти одночасно ітеруєш по властивостях, фільтруєш їх та трансформуєш їхні імена — все на рівні типів.
Практичний приклад: типи валідації форм#
Я використовував цей патерн у кількох бібліотеках форм:
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>;Додай поле до FormFields, і кожен пов'язаний тип оновиться автоматично. Видали поле, і компілятор зловить кожне посилання. Це саме той тип речей, що запобігає цілим категоріям багів.
Template Literal Types#
Template literal types дозволяють маніпулювати рядками на рівні типів. Вони використовують той самий синтаксис з бектіками, що й JavaScript template literals, але для типів.
Базова маніпуляція рядками#
type Greeting = `Hello, ${string}`;
const a: Greeting = "Hello, world"; // OK
const b: Greeting = "Hello, TypeScript"; // OK
// const c: Greeting = "Hi, world"; // Error!TypeScript також надає вбудовані типи для маніпуляції рядками:
type Upper = Uppercase<"hello">; // "HELLO"
type Lower = Lowercase<"HELLO">; // "hello"
type Cap = Capitalize<"hello">; // "Hello"
type Uncap = Uncapitalize<"Hello">; // "hello"Патерни імен подій#
Ось де template literal types по-справжньому сяють. Патерн, який я використовую для типобезпечних систем подій:
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"Дев'ять подій з двох параметрів типу. І всі вони типобезпечні.
CSS-in-TypeScript патерни#
Template literal types можуть забезпечувати CSS-патерни на рівні типів:
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"); // ErrorПарсинг рядків за допомогою Template Literals#
Можна використовувати template literal types з infer для парсингу рядків:
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"Це основа того, як працюють типобезпечні бібліотеки маршрутизації на кшталт tRPC. Рядок /users/:userId/posts/:postId парситься на рівні типів для витягнення імен параметрів.
Комбінування Template Literals з Mapped Types#
Ось де речі стають по-справжньому потужними:
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;
// }З простого конфігураційного інтерфейсу отримуємо повністю типізовані обробники подій з правильними типами параметрів. Це саме той тип коду на рівні типів, що реально економить час розробки.
Просунуте: типи шляхів через крапку#
Ось більш просунутий патерн — генерація всіх можливих шляхів через крапку в вкладеному об'єкті:
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"Тепер ти можеш написати функцію getConfig, яка автодоповнює кожен можливий шлях і повертає правильний тип для кожного:
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: stringПовне автодоповнення, повна типобезпечність, нуль рантайм-накладних витрат від типів.
Рекурсивні типи#
TypeScript підтримує рекурсивні визначення типів, що дозволяє працювати з довільно вкладеними структурами.
Deep Partial#
Вбудований Partial<T> робить необов'язковими лише властивості верхнього рівня. Для вкладених об'єктів потрібна рекурсія:
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 optionalЗверни увагу на перевірку масиву: без неї масиви оброблялися б як об'єкти, і їхні числові індекси стали б необов'язковими, що не є тим, що ти хочеш.
Deep Readonly#
Той самий патерн, інший модифікатор:
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];
};Перевірка Function важлива — без неї до внутрішньої структури функціональних властивостей застосувався б readonly, що не має сенсу.
JSON Type#
Ось класичний рекурсивний тип — представлення будь-якого валідного 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);
}Це набагато краще, ніж використовувати any для парсингу JSON. Воно повідомляє споживачу: «ти отримаєш щось назад, але тобі потрібно звузити тип перед використанням». Що є абсолютною правдою.
Рекурсивний тип для вкладених меню#
Практичний приклад — типізація структури навігаційного меню:
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" },
];Рекурсивна властивість children дає необмежену вкладеність з повною типобезпечністю.
Реальні патерни#
Дозволь поділитися деякими патернами, які я фактично використовував у продакшн-системах.
Патерн Builder з типами#
Патерн builder стає значно кориснішим, коли система типів відстежує, що було встановлено:
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 missingМетод build() стає доступним для виклику лише тоді, коли всі обов'язкові поля були встановлені. Це ловиться під час компіляції, а не під час виконання.
Типобезпечний Event Emitter#
Ось event emitter, де типи payload примусово перевіряються:
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 AppEventsЯ використовував цей патерн у трьох різних продакшн-проєктах. Він ловить цілі категорії багів, пов'язаних з подіями, під час компіляції.
Discriminated Unions з вичерпною перевіркою#
Discriminated unions — це, напевно, найкорисніший патерн у 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:
// This line ensures exhaustive checking
const _exhaustive: never = shape;
return _exhaustive;
}
}Якщо хтось додасть новий варіант фігури (скажімо, "pentagon"), ця функція не скомпілюється, тому що never не може бути присвоєно значення. Компілятор змушує тебе обробити кожен випадок.
Я розвиваю це далі за допомогою хелпер-функції:
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 також чудово моделюють стейт-машини:
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");
}
}Кожен стан має лише ті властивості, які мають сенс для цього стану. Ти не можеш звернутися до socket, коли з'єднання розірвано. Ти не можеш звернутися до error, коли з'єднання встановлено. Система типів забезпечує обмеження стейт-машини.
Branded Types для доменної безпеки#
Ще один патерн, який я вважаю незамінним — використання branded types для запобігання змішуванню значень, які мають один і той самий базовий тип:
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 UserIdІ UserId, і OrderId — це рядки під час виконання. Але під час компіляції це різні типи. Ти буквально не можеш передати order ID туди, де очікується user ID. Це ловило реальні баги в кожному проєкті, де я це використовував.
Поширені підводні камені#
Просунуті типи потужні, але мають свої пастки. Ось що я вивчив на власному гіркому досвіді.
Обмеження циклічних посилань#
TypeScript має обмеження глибини рекурсії для типів (наразі приблизно 50 рівнів, хоча це варіюється). Якщо зайдеш надто глибоко, отримаєш грізну помилку «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];
};Трюк з лічильником глибини використовує кортеж, що зростає з кожним рекурсивним кроком. Коли він досягає твого ліміту, рекурсія зупиняється.
Вплив на продуктивність#
Складні типи можуть значно уповільнити твій редактор та час збірки. Я бачив проєкти, де один надміру хитромудрий тип додавав 3 секунди до зворотного зв'язку кожного натискання клавіші.
Тривожні ознаки:
- Твій IDE потребує більше 2 секунд, щоб показати автодоповнення
tsc --noEmitпомітно повільнішає після додавання типу- Ти бачиш помилки «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 elementsКоли НЕ використовувати просунуті типи#
Це, мабуть, найважливіший розділ. Просунуті типи слід використовувати, коли вони запобігають багам або покращують досвід розробника. Їх не слід використовувати для самоствердження.
Не використовуй їх, коли:
- Простий
Record<string, unknown>був би достатнім - Тип використовується лише в одному місці, і конкретний тип був би зрозумілішим
- Ти витрачаєш більше часу на відлагодження типу, ніж він заощадив би
- Твоя команда не може його прочитати чи підтримувати
- Перевірка під час виконання була б доречнішою
Використовуй їх, коли:
- У тебе є патерн, який повторюється в багатьох типах (mapped types)
- Тип повернення функції залежить від її вхідних даних (conditional types)
- Ти будуєш API бібліотеки, яке має бути самодокументованим
- Ти хочеш зробити неможливі стани непредставимими
- Ти втомився від однієї й тієї ж категорії багів на 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;
}Моє правило: якщо пояснення типу займає більше часу, ніж пояснення бага, який він запобігає, — спрости його.
Відлагодження складних типів#
Коли складний тип не працює, я використовую цей хелпер, щоб «побачити», що TypeScript розв'язав:
// 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 typeТрюк з & {} змушує TypeScript жадібно обчислити тип замість того, щоб показувати аліас типу. Це заощадило мені годин плутанини.
Ще одна техніка — ізолюй та тестуй поступово:
// 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 checkПідсумок#
- Conditional types (
T extends U ? X : Y) — це if/else для типів. Стережись дистрибутивної поведінки з union. infer— це патерн-матчинг — використовуй для витягнення типів зі структур на кшталт сигнатур функцій, promise та масивів.- Mapped types (
{ [K in keyof T]: ... }) ітерують по властивостях. Комбінуй зasдля ремапінгу ключів та фільтрації. - Template literal types маніпулюють рядками на рівні типів. У комбінації з mapped types вони неймовірно потужні для дизайну API.
- Рекурсивні типи обробляють вкладені структури, але потребують обмежень глибини, щоб уникнути вибухів компілятора.
- Discriminated unions з вичерпною перевіркою — це найцінніший патерн у TypeScript. Використовуй їх всюди.
- Branded types запобігають змішуванню значень, що мають один і той самий базовий тип. Прості у реалізації, високий вплив.
- Не переускладнюй типи. Якщо тип складніше зрозуміти, ніж баг, який він запобігає, — спрости. Мета — зробити твою кодову базу безпечнішою, а не виграти змагання з type golf.
Система типів TypeScript є Тюрінг-повною, а отже ти можеш зробити з нею майже все. Мистецтво полягає в тому, щоб знати, коли варто.