Перейти к содержимому
·19 мин чтения

Продвинутые типы TypeScript: условные, mapped и template literal типы

Глубокое погружение в самые мощные возможности системы типов TypeScript — условные типы, mapped-типы, ключевое слово infer и template literal типы. Реальные паттерны, которые ты действительно будешь использовать.

Поделиться:X / TwitterLinkedIn

Большинство TypeScript-разработчиков застревают на одном и том же месте. Они знают интерфейсы, дженерики, union-типы. Могут типизировать React-компонент. Могут убрать красные подчёркивания. Но относятся к системе типов как к неизбежному злу — чему-то, что существует для предотвращения багов, а не помогает проектировать более качественный софт.

Я завис на этом плато примерно два года. Вытащило меня осознание того, что система типов TypeScript — это сам по себе язык программирования. У неё есть условия, циклы, паттерн-матчинг, рекурсия и работа со строками. Как только ты это усвоишь, всё меняется. Ты перестаёшь бороться с компилятором и начинаешь сотрудничать с ним.

В этом посте я разберу фичи системы типов, которые постоянно использую в продакшн-коде. Никаких академических упражнений — только реальные паттерны, которые спасли меня от реальных багов.

Мышление в терминах типов#

Прежде чем нырять в синтаксис, хочу переосмыслить твой подход к типам.

В программировании на уровне значений ты пишешь функции, которые трансформируют данные в рантайме:

typescript
function extractIds(users: User[]): number[] {
  return users.map(u => u.id);
}

В программировании на уровне типов ты пишешь типы, которые трансформируют другие типы на этапе компиляции:

typescript
type ExtractIds<T extends { id: unknown }[]> = T[number]["id"];

Ментальная модель та же — вход, трансформация, выход. Разница в том, что код на уровне типов выполняется при компиляции, а не в рантайме. Он порождает ноль JavaScript. Его единственная цель — сделать невозможные состояния непредставимыми.

Если ты когда-нибудь писал функцию, где три из четырёх параметров имеют смысл только в определённых комбинациях, или где тип возвращаемого значения зависит от того, что ты передаёшь, — тебе уже нужно было программирование на уровне типов. Ты просто мог не знать, что TypeScript это умеет.

Условные типы#

Условные типы — это if/else системы типов. Синтаксис напоминает тернарный оператор:

typescript
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:

typescript
type MyNonNullable<T> = T extends null | undefined ? never : T;
 
type Test = MyNonNullable<string | null | undefined>;
// Результат: string

Тип never — это «пустое множество», удаление элемента из union. Когда ветка условного типа разрешается в never, этот элемент union просто исчезает.

Extract — оставляет только элементы union, соответствующие ограничению:

typescript
type MyExtract<T, U> = T extends U ? T : never;
 
type Numbers = Extract<string | number | boolean, number | boolean>;
// Результат: number | boolean

Exclude — противоположность, удаляет подходящие элементы:

typescript
type MyExclude<T, U> = T extends U ? never : T;
 
type WithoutStrings = Exclude<string | number | boolean, string>;
// Результат: number | boolean

Дистрибутивные условные типы#

Вот тут становится интересно, и именно тут большинство людей путается. Когда ты передаёшь union-тип в условный тип, TypeScript распределяет условие по каждому элементу union отдельно.

typescript
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 — это «голый» параметр типа (не обёрнутый ни во что).

Если хочешь предотвратить распределение, оберни обе стороны в кортеж:

typescript
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
 
type Result = ToArrayNonDist<string | number>;
// Результат: (string | number)[]

На этом я обжигался чаще, чем готов признать. Если условный тип выдаёт неожиданные результаты с union-входами, виновато почти всегда распределение.

Практический пример: обработчик API-ответов#

Вот паттерн, который я использую при работе с API-ответами, которые могут быть успешными или неудачными:

typescript
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 определит за тебя.

Думай об этом так: «Я знаю, что у этого типа определённая форма. Вытащи часть, которая мне нужна».

Извлечение возвращаемых типов#

typescript
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
 
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // boolean

TypeScript видит паттерн (...args) => something, сопоставляет твою функцию с ним и присваивает возвращаемый тип переменной R.

Извлечение типов параметров#

typescript
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]

Разворачивание промисов#

Этот паттерн я использую постоянно при работе с асинхронными функциями:

typescript
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 (проброс)

Для глубоко вложенных промисов:

typescript
type DeepUnpackPromise<T> = T extends Promise<infer U>
  ? DeepUnpackPromise<U>
  : T;
 
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Результат: number

Извлечение типа элемента массива#

typescript
type ElementOf<T> = T extends (infer E)[] ? E : never;
 
type Items = ElementOf<string[]>;         // string
type Mixed = ElementOf<(string | number)[]>; // string | number

Несколько infer в одном типе#

Можно использовать infer несколько раз для извлечения разных частей:

typescript
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 }

Реальный кейс: типобезопасное извлечение событий#

Вот паттерн, который я использую в событийно-ориентированных системах:

typescript
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() системы типов.

Основы#

typescript
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 — делает все свойства обязательными:

typescript
type MyRequired<T> = {
  [K in keyof T]-?: T[K];
};

Синтаксис -? убирает модификатор опциональности. Аналогично -readonly убирает readonly.

Pick — выбирает конкретные свойства:

typescript
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 — убирает конкретные свойства:

typescript
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 — создаёт тип объекта с определёнными ключами и типами значений:

typescript
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 ввёл ремаппинг ключей, который позволяет трансформировать имена свойств:

typescript
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:

typescript
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};
 
type StringProps = OnlyStrings<Person>;
// { name: string; email: string }

Это невероятно мощно. Ты одновременно перебираешь свойства, фильтруешь их и трансформируешь имена — всё на уровне типов.

Практический пример: типы валидации форм#

Этот паттерн я использовал в нескольких библиотеках для форм:

typescript
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, но для типов.

Базовые манипуляции со строками#

typescript
type Greeting = `Hello, ${string}`;
 
const a: Greeting = "Hello, world";    // OK
const b: Greeting = "Hello, TypeScript"; // OK
// const c: Greeting = "Hi, world";    // Ошибка!

TypeScript также предоставляет встроенные типы для манипуляции строками:

typescript
type Upper = Uppercase<"hello">;     // "HELLO"
type Lower = Lowercase<"HELLO">;     // "hello"
type Cap = Capitalize<"hello">;      // "Hello"
type Uncap = Uncapitalize<"Hello">;  // "hello"

Паттерны имён событий#

Вот где template literal типы по-настоящему блистают. Паттерн для типобезопасных систем событий:

typescript
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-паттерны на уровне типов:

typescript
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 для парсинга строк:

typescript
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-типами#

Вот тут вещи становятся по-настоящему мощными:

typescript
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-нотации:

typescript
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, которая автодополняет каждый возможный путь и возвращает правильный тип для каждого:

typescript
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> делает опциональными только свойства верхнего уровня. Для вложенных объектов нужна рекурсия:

typescript
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#

Тот же паттерн, другой модификатор:

typescript
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-значения:

typescript
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. Это говорит потребителю: «Ты получишь что-то, но тебе нужно сузить тип перед использованием». Что является чистой правдой.

Рекурсивный тип для вложенных меню#

Практический пример — типизация структуры навигационного меню:

typescript
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 становится значительно полезнее, когда система типов отслеживает, что было установлено:

typescript
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'ов строго проверяются:

typescript
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. В сочетании с исчерпывающей проверкой они гарантируют, что ты обработаешь каждый случай:

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 нельзя присвоить значение. Компилятор заставляет тебя обработать каждый случай.

Я иду дальше и использую вспомогательную функцию:

typescript
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}`);
  }
}

Типобезопасные конечные автоматы#

Размеченные объединения также прекрасно моделируют конечные автоматы:

typescript
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-типов для предотвращения путаницы между значениями, которые имеют один и тот же базовый тип:

typescript
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».

typescript
// Упрётся в лимит рекурсии для глубоко вложенных объектов
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»
typescript
// Слишком хитро — генерирует комбинаторный взрыв
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 библиотеки, который должен быть самодокументируемым
  • Хочешь сделать некорректные состояния непредставимыми
  • Устал от одной и той же категории багов на код-ревью
typescript
// Переусложнение — не делай так для простого конфига
type Config = DeepReadonly<
  DeepRequired<
    Merge<DefaultConfig, Partial<UserConfig>>
  >
>;
 
// Просто сделай так
interface Config {
  readonly host: string;
  readonly port: number;
  readonly debug: boolean;
}

Моё правило: если объяснение типа занимает больше времени, чем объяснение бага, который он предотвращает — упрости его.

Отладка сложных типов#

Когда сложный тип не работает, я использую этот хелпер, чтобы «увидеть» что TypeScript разрешил:

typescript
// Раскрывает тип для инспекции в тултипах IDE при наведении
type Prettify<T> = {
  [K in keyof T]: T[K];
} & {};
 
// Использование для отладки
type Problematic = SomeComplexType<Input>;
type Debug = Prettify<Problematic>;
// Теперь наведи курсор на Debug в IDE, чтобы увидеть разрешённый тип

Трюк с & {} заставляет TypeScript жадно вычислить тип вместо отображения алиаса. Это сэкономило мне часы путаницы.

Ещё один приём — изолировать и тестировать пошагово:

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 тьюринг-полна, а значит, ты можешь сделать с ней практически что угодно. Искусство в том, чтобы знать, когда ты должен.

Похожие записи