Глибоке занурення в найпотужніші можливості системи типів 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 — це 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Ось де стає цікаво, і де більшість людей плутається. Коли ти передаєш 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-відповідями, що можуть бути успішними або з помилкою:
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 для кожної форми відповіді.
inferinfer — це патерн-матчинг для типів. Воно дозволяє оголосити змінну типу всередині 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]Цим я користуюся постійно при роботі з асинхронними функціями:
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: 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]>;
// 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 дозволяють створювати нові об'єктні типи, трансформуючи кожну властивість існуючого типу. Вони — 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 }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;
};
// Now your form state is fully typed based on the fields
type MyFormState = FormState<FormFields>;Додай поле до FormFields, і кожен пов'язаний тип оновиться автоматично. Видали поле, і компілятор зловить кожне посилання. Це саме той тип речей, що запобігає цілим категоріям багів.
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"Дев'ять подій з двох параметрів типу. І всі вони типобезпечні.
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 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 парситься на рівні типів для витягнення імен параметрів.
Ось де речі стають по-справжньому потужними:
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 підтримує рекурсивні визначення типів, що дозволяє працювати з довільно вкладеними структурами.
Вбудований 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Зверни увагу на перевірку масиву: без неї масиви оброблялися б як об'єкти, і їхні числові індекси стали б необов'язковими, що не є тим, що ти хочеш.
Той самий патерн, інший модифікатор:
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>;
}
}
// 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, де типи 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 — це, напевно, найкорисніший патерн у 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 для запобігання змішуванню значень, які мають один і той самий базовий тип:
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 секунди до зворотного зв'язку кожного натискання клавіші.
Тривожні ознаки:
tsc --noEmit помітно повільнішає після додавання типу// 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> був би достатнімВикористовуй їх, коли:
// 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 checkT extends U ? X : Y) — це if/else для типів. Стережись дистрибутивної поведінки з union.infer — це патерн-матчинг — використовуй для витягнення типів зі структур на кшталт сигнатур функцій, promise та масивів.{ [K in keyof T]: ... }) ітерують по властивостях. Комбінуй з as для ремапінгу ключів та фільтрації.Система типів TypeScript є Тюрінг-повною, а отже ти можеш зробити з нею майже все. Мистецтво полягає в тому, щоб знати, коли варто.