О насБлогКонтакты
Разработка17 апреля 2026 г. 6 мин 2

TypeScript продвинутые типы: conditional types, infer и mapped types на реальных примерах

AunimedaAunimeda
📋 Содержание

TypeScript продвинутые типы: conditional types, infer и mapped types на реальных примерах

Большинство разработчиков используют TypeScript как «JavaScript с аннотациями типов» — string, number, интерфейсы и на этом всё. Но система типов TS — это тьюринг-полный язык программирования на уровне типов. Когда научишься им пользоваться, начинаешь ловить целые классы ошибок на этапе компиляции, которые раньше обнаруживались только в runtime.

Весь код в этой статье — реальные паттерны из production кодовых баз. Никаких надуманных примеров.


Conditional Types: if/else на уровне типов

Базовый синтаксис: T extends U ? X : Y. Если тип T extends U — результат X, иначе Y.

type IsArray<T> = T extends any[] ? true : false;

type A = IsArray<string[]>; // true
type B = IsArray<string>;   // false

Но настоящая сила — в дистрибутивности. Когда T — naked type parameter, conditional type применяется к каждому члену union:

type NonNullable<T> = T extends null | undefined ? never : T;

type Result = NonNullable<string | null | undefined | number>;
// string | number

never в union исчезает — именно так TypeScript фильтрует типы из union. Это используется в стандартной библиотеке TS повсеместно.


infer: извлекаем типы из других типов

infer работает только внутри extends в conditional type. Он говорит: «определи переменную типа здесь и используй её».

Извлекаем тип промиса

type Awaited<T> = T extends Promise<infer R> ? R : T;

type A = Awaited<Promise<string>>;         // string
type B = Awaited<Promise<Promise<number>>>; // Promise<number> — не рекурсивно!

// Рекурсивная версия:
type DeepAwaited<T> = T extends Promise<infer R> ? DeepAwaited<R> : T;
type C = DeepAwaited<Promise<Promise<number>>>; // number

Извлекаем тип возвращаемого значения функции

type ReturnType<T extends (...args: any) => any> = 
  T extends (...args: any) => infer R ? R : never;

async function fetchUser(id: string): Promise<{ id: string; email: string }> {
  // ...
}

type UserResponse = Awaited<ReturnType<typeof fetchUser>>;
// { id: string; email: string }

Этот паттерн незаменим при работе с API: вместо дублирования типов выводишь их из функции.

Параметры функции

type Parameters<T extends (...args: any) => any> = 
  T extends (...args: infer P) => any ? P : never;

function createOrder(userId: string, items: string[], total: number): void {}

type OrderParams = Parameters<typeof createOrder>;
// [userId: string, items: string[], total: number]

// Первый параметр:
type FirstParam = OrderParams[0]; // string

Mapped Types: трансформируем форму объекта

Mapped type итерирует по ключам и создаёт новый тип:

type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};

type Partial<T> = {
  [K in keyof T]?: T[K];
};

type Required<T> = {
  [K in keyof T]-?: T[K]; // -? убирает опциональность
};

Реальный кейс: типобезопасные ключи для локализации

type Locale = 'ru' | 'en' | 'kz';

type Translations = {
  [K in Locale]: string;
};

// Теперь TypeScript заставит указать все локали:
const buttonLabel: Translations = {
  ru: 'Отправить',
  en: 'Submit',
  kz: 'Жіберу',
  // Если пропустить хоть одну — ошибка компиляции
};

Фильтрация ключей через mapped type + conditional

// Оставляем только ключи, значения которых — строки
type StringKeys<T> = {
  [K in keyof T]: T[K] extends string ? K : never;
}[keyof T];

type User = {
  id: number;
  email: string;
  name: string;
  age: number;
  role: string;
};

type UserStringKeys = StringKeys<User>; // 'email' | 'name' | 'role'

// Применяем: тип для поиска пользователей по строковым полям
type UserSearch = {
  [K in StringKeys<User>]?: string;
};
// { email?: string; name?: string; role?: string }

Template Literal Types: строгая типизация строк

С TypeScript 4.1 можно создавать строковые типы через шаблоны:

type EventName = `on${Capitalize<string>}`; 
// 'onClick', 'onChange', etc.

// Строгая типизация путей API
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiVersion = 'v1' | 'v2';
type Endpoint = 'users' | 'orders' | 'products';

type ApiPath = `/${ApiVersion}/${Endpoint}`;
// '/v1/users' | '/v1/orders' | '/v1/products' | '/v2/users' | ...

type ApiRoute = `${HttpMethod} ${ApiPath}`;
// 'GET /v1/users' | 'POST /v1/users' | ...

// Конфигурация роутера строго типизирована:
const routes: Record<ApiRoute, () => void> = {
  'GET /v1/users': () => {},
  'POST /v1/orders': () => {},
  // TypeScript поймает опечатку: 'GET /v3/users' — ошибка!
  // TypeScript поймает 'GOT /v1/users' — ошибка!
};

Реальный кейс: типобезопасный Event Emitter

Это один из самых полезных паттернов. Стандартный EventEmitter — это on(event: string, handler: (...args: any[]) => void). Никакой типобезопасности.

type EventMap = {
  'user:created': { id: string; email: string };
  'order:placed': { orderId: string; total: number };
  'payment:failed': { orderId: string; reason: string };
};

type EventHandler<T extends Record<string, any>, K extends keyof T> = 
  (payload: T[K]) => void;

class TypedEmitter<T extends Record<string, any>> {
  private listeners: Partial<{
    [K in keyof T]: Array<EventHandler<T, K>>;
  }> = {};

  on<K extends keyof T>(event: K, handler: EventHandler<T, K>): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(handler);
  }

  emit<K extends keyof T>(event: K, payload: T[K]): void {
    this.listeners[event]?.forEach(handler => handler(payload));
  }
}

const emitter = new TypedEmitter<EventMap>();

// Полная типобезопасность — TypeScript знает тип payload для каждого события:
emitter.on('user:created', ({ id, email }) => {
  console.log(id, email); // id: string, email: string — автодополнение работает
});

emitter.emit('order:placed', { orderId: '123', total: 4500 });
// emitter.emit('order:placed', { orderId: '123' }); // TS ERROR: missing 'total'

DeepPartial и DeepReadonly: рекурсивные mapped types

type DeepPartial<T> = T extends object
  ? { [P in keyof T]?: DeepPartial<T[P]> }
  : T;

type DeepReadonly<T> = T extends (infer R)[]
  ? ReadonlyArray<DeepReadonly<R>>
  : T extends object
  ? { readonly [P in keyof T]: DeepReadonly<T[P]> }
  : T;

// Использование:
type Config = {
  database: {
    host: string;
    port: number;
    credentials: {
      user: string;
      password: string;
    };
  };
  cache: {
    ttl: number;
  };
};

// Для патч-обновлений конфига — все поля опциональны рекурсивно:
function updateConfig(patch: DeepPartial<Config>): void {
  // patch.database?.credentials?.user — TypeScript всё понимает
}

Discriminated Unions + Exhaustiveness Checking

Паттерн, который ловит ошибки при добавлении нового варианта:

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 (shape.base * shape.height) / 2;
    default:
      // Если добавить новый вид фигуры и забыть обработать — TS ошибка здесь:
      const _exhaustive: never = shape;
      throw new Error(`Unknown shape: ${_exhaustive}`);
  }
}

Когда добавляем | { kind: 'ellipse'; a: number; b: number }, TypeScript немедленно укажет на default ветку — потому что shape уже не never.


Итог: когда это реально нужно

Продвинутые типы — не самоцель. Они оправданы, когда:

  • Пишете библиотеку или фреймворк, которым пользуется команда
  • Строите типобезопасный API-клиент (extract types из схемы)
  • Хотите, чтобы добавление нового варианта enum/union автоматически требовало обновить все обработчики
  • Работаете с event-driven системами (типобезопасный emitter)

Для рядового CRUD-контроллера Partial и Readonly обычно достаточно. Не усложняйте без нужды.


Aunimeda строит production TypeScript системы с глубокой типизацией. Связаться с нами.

Смотрите также: tRPC + Zod типобезопасность, Разработка ПО

Читайте также

Node.js vs Bun vs Deno 2026: какой JavaScript runtime выбратьaunimeda
Разработка

Node.js vs Bun vs Deno 2026: какой JavaScript runtime выбрать

Bun 1.x стабилен в production. Deno 2.0 поддерживает npm. Node.js 22 запускает TypeScript нативно. Реальные бенчмарки, сравнение экосистем и конкретные рекомендации — для новых и существующих проектов.

State of JavaScript 2026: что изменилось и куда движется экосистемаaunimeda
Разработка

State of JavaScript 2026: что изменилось и куда движется экосистема

Vite обошёл webpack. TypeScript — дефолт для новых проектов. React сохраняет доминирование, но Signal-based фреймворки растут. AI-assisted coding меняет что значит 'написать код'. Честный разбор состояния JavaScript-экосистемы в 2026.

Чистая архитектура в Node.js: практическое руководство без академизмаaunimeda
Разработка

Чистая архитектура в Node.js: практическое руководство без академизма

Чистая архитектура звучит хорошо в теории. На практике большинство реализаций добавляют сложность без пользы. Показываем паттерн, который реально работает в production Node.js TypeScript проектах — инверсия зависимостей, use cases, repository pattern с рабочим кодом.

Нужна IT-разработка для вашего бизнеса?

Разрабатываем сайты, мобильные приложения и AI-решения для бизнеса в России. Бесплатная консультация.

Получить консультацию Все статьи