Продвинутые типы TypeScript: условные, mapped и template literal типы
Глубокое погружение в самые мощные возможности системы типов TypeScript — условные типы, mapped-типы, ключевое слово infer и template literal типы. Реальные паттерны, которые ты действительно будешь использовать.
Большинство 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 это умеет.
Условные типы#
Условные типы — это 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>;
// Результат: stringТип never — это «пустое множество», удаление элемента из union. Когда ветка условного типа разрешается в never, этот элемент union просто исчезает.
Extract — оставляет только элементы union, соответствующие ограничению:
type MyExtract<T, U> = T extends U ? T : never;
type Numbers = Extract<string | number | boolean, number | boolean>;
// Результат: number | booleanExclude — противоположность, удаляет подходящие элементы:
type MyExclude<T, U> = T extends U ? never : T;
type WithoutStrings = Exclude<string | number | boolean, string>;
// Результат: number | booleanДистрибутивные условные типы#
Вот тут становится интересно, и именно тут большинство людей путается. Когда ты передаёшь union-тип в условный тип, TypeScript распределяет условие по каждому элементу union отдельно.
type ToArray<T> = T extends unknown ? T[] : never;
type Result = ToArray<string | number>;
// Можно ожидать: (string | number)[]
// Реальный результат: 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>;
// Результат: (string | number)[]На этом я обжигался чаще, чем готов признать. Если условный тип выдаёт неожиданные результаты с 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>;
// Результат: { id: number; name: string }
type UserError = ExtractError<UserResponse>;
// Результат: stringЭто чище, чем писать ручные type guard для каждой формы ответа.
Ключевое слово infer#
infer — это паттерн-матчинг для типов. Оно позволяет объявить переменную типа внутри условного типа, которую 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]Разворачивание промисов#
Этот паттерн я использую постоянно при работе с асинхронными функциями:
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 (проброс)Для глубоко вложенных промисов:
type DeepUnpackPromise<T> = T extends Promise<infer U>
? DeepUnpackPromise<U>
: T;
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Результат: 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]>;
// Результат: { 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];
// Извлечь все события, связанные с пользователем
type UserEvents = Extract<EventName, `user:${string}`>;
// Результат: "user:login" | "user:logout"
// Получить payload только для пользовательских событий
type UserEventPayloads = EventPayload<UserEvents>;
// Результат: { userId: string; timestamp: number } | { userId: string }Mapped-типы#
Mapped-типы позволяют создавать новые типы объектов путём трансформации каждого свойства существующего типа. Это 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];
};Синтаксис -? убирает модификатор опциональности. Аналогично -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;
};
// Теперь состояние формы полностью типизировано на основе полей
type MyFormState = FormState<FormFields>;Добавь поле в FormFields, и каждый связанный тип обновится автоматически. Убери поле, и компилятор поймает каждую ссылку. Именно такие вещи предотвращают целые категории багов.
Template literal типы#
Template literal типы позволяют манипулировать строками на уровне типов. Они используют тот же синтаксис с обратными кавычками, что и template literal в JavaScript, но для типов.
Базовые манипуляции со строками#
type Greeting = `Hello, ${string}`;
const a: Greeting = "Hello, world"; // OK
const b: Greeting = "Hello, TypeScript"; // OK
// const c: Greeting = "Hi, world"; // Ошибка!TypeScript также предоставляет встроенные типы для манипуляции строками:
type Upper = Uppercase<"hello">; // "HELLO"
type Lower = Lowercase<"HELLO">; // "hello"
type Cap = Capitalize<"hello">; // "Hello"
type Uncap = Uncapitalize<"Hello">; // "hello"Паттерны имён событий#
Вот где template literal типы по-настоящему блистают. Паттерн для типобезопасных систем событий:
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-в-TypeScript паттерны#
Template literal типы могут обеспечивать CSS-паттерны на уровне типов:
type CSSUnit = "px" | "rem" | "em" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;
function setWidth(value: CSSValue): void {
// реализация
}
setWidth("100px"); // OK
setWidth("2.5rem"); // OK
setWidth("50%"); // OK
// setWidth("100"); // Ошибка: не валидное CSS-значение
// setWidth("big"); // ОшибкаПарсинг строк с template literal#
Можно использовать template literal типы с 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">;
// Результат: "userId" | "postId"Это основа того, как работают типобезопасные библиотеки маршрутизации вроде tRPC. Строка /users/:userId/posts/:postId парсится на уровне типов для извлечения имён параметров.
Комбинирование template literal с mapped-типами#
Вот тут вещи становятся по-настоящему мощными:
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;
// }Из простого интерфейса конфигурации ты получаешь полностью типизированные обработчики событий с правильными типами параметров. Это именно тот код на уровне типов, который реально экономит время разработки.
Продвинутый паттерн: типы для dot-нотации путей#
Более продвинутый паттерн — генерация всех возможных путей через вложенный объект в dot-нотации:
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> {
// реализация
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"); // тип: string
const port = getConfig("database.port"); // тип: number
const username = getConfig("database.credentials.username"); // тип: 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>;
// Каждое вложенное свойство теперь опциональноеОбрати внимание на проверку массива: без неё массивы трактовались бы как объекты, и их числовые индексы стали бы опциональными, что тебе не нужно.
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#
Классический рекурсивный тип — представление любого валидного 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>;
}
}
// Компилируется:
const config = new DatabaseConfigBuilder()
.setHost("localhost")
.setPort(5432)
.setDatabase("myapp")
.setSsl(true)
.build();
// Ошибка компиляции — не хватает .setDatabase():
// const bad = new DatabaseConfigBuilder()
// .setHost("localhost")
// .setPort(5432)
// .build(); // Ошибка: отсутствует 'database'Метод build() становится вызываемым только когда все обязательные поля установлены. Это ловится на этапе компиляции, а не в рантайме.
Типобезопасный 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;
}
}
}
// Использование
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>();
// Полная типизация — IDE автодополняет имена событий и формы payload
emitter.on("user:login", (payload) => {
console.log(payload.userId); // string
console.log(payload.timestamp); // number
});
// Ошибка типа: payload не соответствует
// emitter.emit("user:login", { userId: "123" });
// Ошибка: отсутствует 'timestamp'
// Ошибка типа: событие не существует
// emitter.on("user:signup", () => {});
// Ошибка: "user:signup" нет в AppEventsЯ использовал этот паттерн в трёх разных продакшн-проектах. Он ловит целые категории багов, связанных с событиями, на этапе компиляции.
Размеченные объединения с исчерпывающей проверкой#
Размеченные объединения (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:
// Эта строка гарантирует исчерпывающую проверку
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}`);
}
}Типобезопасные конечные автоматы#
Размеченные объединения также прекрасно моделируют конечные автоматы:
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 в состоянии disconnected. Нельзя обратиться к error в состоянии connected. Система типов обеспечивает ограничения конечного автомата.
Branded-типы для доменной безопасности#
Ещё один паттерн, который считаю необходимым — использование branded-типов для предотвращения путаницы между значениями, которые имеют один и тот же базовый тип:
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); // Ошибка! OrderId не присваивается к UserIdИ UserId, и OrderId — строки в рантайме. Но на этапе компиляции это различные типы. Ты буквально не можешь передать ID заказа туда, где ожидается ID пользователя. Это ловило реальные баги в каждом проекте, где я это использовал.
Типичные подводные камни#
Продвинутые типы мощные, но с ними есть ловушки. Вот чему я научился на собственном опыте.
Ограничения глубины рекурсии#
TypeScript имеет ограничение глубины рекурсии для типов (сейчас около 50 уровней, хотя это варьируется). Если зайдёшь слишком глубоко, получишь ужасную ошибку «Type instantiation is excessively deep and possibly infinite».
// Упрётся в лимит рекурсии для глубоко вложенных объектов
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
// Исправление: счётчик глубины
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»
// Слишком хитро — генерирует комбинаторный взрыв
type AllCombinations<T extends string[]> = T extends [
infer First extends string,
...infer Rest extends string[]
]
? `${First}${AllCombinations<Rest>}` | AllCombinations<Rest>
: "";
// Не делай так с более чем 5-6 элементамиКогда НЕ стоит использовать продвинутые типы#
Возможно, это самый важный раздел. Продвинутые типы стоит использовать, когда они предотвращают баги или улучшают опыт разработки. Не стоит использовать их для того, чтобы выпендриться.
Не используй их, когда:
- Хватило бы простого
Record<string, unknown> - Тип используется только в одном месте, и конкретный тип был бы понятнее
- Ты тратишь на отладку типа больше времени, чем он сэкономит
- Твоя команда не может его прочитать или поддерживать
- Проверка в рантайме была бы уместнее
Используй их, когда:
- У тебя есть паттерн, повторяющийся во многих типах (mapped-типы)
- Возвращаемый тип функции зависит от входных данных (условные типы)
- Ты строишь API библиотеки, который должен быть самодокументируемым
- Хочешь сделать некорректные состояния непредставимыми
- Устал от одной и той же категории багов на код-ревью
// Переусложнение — не делай так для простого конфига
type Config = DeepReadonly<
DeepRequired<
Merge<DefaultConfig, Partial<UserConfig>>
>
>;
// Просто сделай так
interface Config {
readonly host: string;
readonly port: number;
readonly debug: boolean;
}Моё правило: если объяснение типа занимает больше времени, чем объяснение бага, который он предотвращает — упрости его.
Отладка сложных типов#
Когда сложный тип не работает, я использую этот хелпер, чтобы «увидеть» что TypeScript разрешил:
// Раскрывает тип для инспекции в тултипах IDE при наведении
type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
// Использование для отладки
type Problematic = SomeComplexType<Input>;
type Debug = Prettify<Problematic>;
// Теперь наведи курсор на Debug в IDE, чтобы увидеть разрешённый типТрюк с & {} заставляет TypeScript жадно вычислить тип вместо отображения алиаса. Это сэкономило мне часы путаницы.
Ещё один приём — изолировать и тестировать пошагово:
// Вместо отладки всего сразу:
type Final = StepThree<StepTwo<StepOne<Input>>>;
// Разбей на части:
type AfterStep1 = StepOne<Input>; // наведи для проверки
type AfterStep2 = StepTwo<AfterStep1>; // наведи для проверки
type AfterStep3 = StepThree<AfterStep2>; // наведи для проверкиИтого#
- Условные типы (
T extends U ? X : Y) — это if/else для типов. Следи за дистрибутивным поведением с union-типами. infer— это паттерн-матчинг. Используй для извлечения типов из структур вроде сигнатур функций, промисов и массивов.- Mapped-типы (
{ [K in keyof T]: ... }) перебирают свойства. Комбинируй сasдля ремаппинга ключей и фильтрации. - Template literal типы манипулируют строками на уровне типов. В сочетании с mapped-типами невероятно мощны для дизайна API.
- Рекурсивные типы работают с вложенными структурами, но нужны ограничения глубины, чтобы избежать взрывов компилятора.
- Размеченные объединения с исчерпывающей проверкой — самый ценный паттерн в TypeScript. Используй их повсюду.
- Branded-типы предотвращают путаницу между значениями одного базового типа. Просты в реализации, высокая отдача.
- Не переусложняй типы. Если тип сложнее для понимания, чем баг, который он предотвращает — упрости его. Цель — сделать кодовую базу безопаснее, а не выиграть соревнование по type golf.
Система типов TypeScript тьюринг-полна, а значит, ты можешь сделать с ней практически что угодно. Искусство в том, чтобы знать, когда ты должен.