Глубокое погружение в самые мощные возможности системы типов 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-ответами, которые могут быть успешными или неудачными:
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 для каждой формы ответа.
inferinfer — это паттерн-матчинг для типов. Оно позволяет объявить переменную типа внутри условного типа, которую 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>>>>;
// Результат: numbertype ElementOf<T> = T extends (infer E)[] ? E : never;
type Items = ElementOf<string[]>; // string
type Mixed = ElementOf<(string | number)[]>; // string | numberinfer в одном типеМожно использовать 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-типы позволяют создавать новые типы объектов путём трансформации каждого свойства существующего типа. Это 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 }asTypeScript 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 в 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"Девять событий из двух параметров типа. И все типобезопасные.
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 типы с 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 парсится на уровне типов для извлечения имён параметров.
Вот тут вещи становятся по-настоящему мощными:
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-нотации:
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 поддерживает рекурсивные определения типов, что позволяет работать с произвольно вложенными структурами.
Встроенный 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>;
// Каждое вложенное свойство теперь опциональноеОбрати внимание на проверку массива: без неё массивы трактовались бы как объекты, и их числовые индексы стали бы опциональными, что тебе не нужно.
Тот же паттерн, другой модификатор:
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 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 становится значительно полезнее, когда система типов отслеживает, что было установлено:
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() становится вызываемым только когда все обязательные поля установлены. Это ловится на этапе компиляции, а не в рантайме.
Вот эмиттер событий, где типы 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-типов для предотвращения путаницы между значениями, которые имеют один и тот же базовый тип:
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 секунды к обратной связи при каждом нажатии клавиши.
Тревожные признаки:
tsc --noEmit заметно замедлился после добавления типа// Слишком хитро — генерирует комбинаторный взрыв
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>Используй их, когда:
// Переусложнение — не делай так для простого конфига
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 — это паттерн-матчинг. Используй для извлечения типов из структур вроде сигнатур функций, промисов и массивов.{ [K in keyof T]: ... }) перебирают свойства. Комбинируй с as для ремаппинга ключей и фильтрации.Система типов TypeScript тьюринг-полна, а значит, ты можешь сделать с ней практически что угодно. Искусство в том, чтобы знать, когда ты должен.