Перейти до вмісту
·19 хв читання

Просунуті типи TypeScript: Conditional, Mapped та Template Literal магія

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

Поділитися: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 це може виразити.

Conditional Types#

Conditional types — це 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>;
// Result: string

Тип never — це «порожня множина» — видалення чогось із union. Коли гілка conditional type розв'язується до never, цей член union просто зникає.

Extract — залишає тільки ті члени union, що відповідають обмеженню:

typescript
type MyExtract<T, U> = T extends U ? T : never;
 
type Numbers = Extract<string | number | boolean, number | boolean>;
// Result: number | boolean

Exclude — протилежність, видаляє відповідні члени:

typescript
type MyExclude<T, U> = T extends U ? never : T;
 
type WithoutStrings = Exclude<string | number | boolean, string>;
// Result: number | boolean

Дистрибутивні Conditional Types#

Ось де стає цікаво, і де більшість людей плутається. Коли ти передаєш union type до conditional type, TypeScript розподіляє умову по кожному члену union окремо.

typescript
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 є «голим» параметром типу (не загорнутим у щось).

Якщо ти хочеш запобігти розподілу, заверни обидві сторони в кортеж:

typescript
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
 
type Result = ToArrayNonDist<string | number>;
// Result: (string | number)[]

Мене це підловило більше разів, ніж я хотів би зізнатися. Якщо твій conditional type дає несподівані результати з 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>;
// Result: { id: number; name: string }
 
type UserError = ExtractError<UserResponse>;
// Result: string

Це чистіше, ніж писати ручні type guards для кожної форми відповіді.

Ключове слово infer#

infer — це патерн-матчинг для типів. Воно дозволяє оголосити змінну типу всередині conditional type, яку 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]

Розгортання Promise#

Цим я користуюся постійно при роботі з асинхронними функціями:

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 (passthrough)

Для глибоко вкладених promise:

typescript
type DeepUnpackPromise<T> = T extends Promise<infer U>
  ? DeepUnpackPromise<U>
  : T;
 
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Result: 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]>;
// Result: { 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];
 
// 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#

Mapped types дозволяють створювати нові об'єктні типи, трансформуючи кожну властивість існуючого типу. Вони — 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];
};

Синтаксис -? видаляє модифікатор optional. Аналогічно, -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;
};
 
// Now your form state is fully typed based on the fields
type MyFormState = FormState<FormFields>;

Додай поле до FormFields, і кожен пов'язаний тип оновиться автоматично. Видали поле, і компілятор зловить кожне посилання. Це саме той тип речей, що запобігає цілим категоріям багів.

Template Literal Types#

Template literal types дозволяють маніпулювати рядками на рівні типів. Вони використовують той самий синтаксис з бектіками, що й JavaScript template literals, але для типів.

Базова маніпуляція рядками#

typescript
type Greeting = `Hello, ${string}`;
 
const a: Greeting = "Hello, world";    // OK
const b: Greeting = "Hello, TypeScript"; // OK
// const c: Greeting = "Hi, world";    // Error!

TypeScript також надає вбудовані типи для маніпуляції рядками:

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

Патерни імен подій#

Ось де template literal types по-справжньому сяють. Патерн, який я використовую для типобезпечних систем подій:

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-in-TypeScript патерни#

Template literal types можуть забезпечувати CSS-патерни на рівні типів:

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

Можна використовувати template literal types з 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">;
// Result: "userId" | "postId"

Це основа того, як працюють типобезпечні бібліотеки маршрутизації на кшталт tRPC. Рядок /users/:userId/posts/:postId парситься на рівні типів для витягнення імен параметрів.

Комбінування Template Literals з Mapped Types#

Ось де речі стають по-справжньому потужними:

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;
// }

З простого конфігураційного інтерфейсу отримуємо повністю типізовані обробники подій з правильними типами параметрів. Це саме той тип коду на рівні типів, що реально економить час розробки.

Просунуте: типи шляхів через крапку#

Ось більш просунутий патерн — генерація всіх можливих шляхів через крапку в вкладеному об'єкті:

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> {
  // 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 підтримує рекурсивні визначення типів, що дозволяє працювати з довільно вкладеними структурами.

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>;
// Every nested property is now optional

Зверни увагу на перевірку масиву: без неї масиви оброблялися б як об'єкти, і їхні числові індекси стали б необов'язковими, що не є тим, що ти хочеш.

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

Ось класичний рекурсивний тип — представлення будь-якого валідного 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>;
  }
}
 
// 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#

Ось 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;
    }
  }
}
 
// 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 з вичерпною перевіркою#

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:
      // This line ensures exhaustive checking
      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}`);
  }
}

Типобезпечні стейт-машини#

Discriminated unions також чудово моделюють стейт-машини:

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, коли з'єднання розірвано. Ти не можеш звернутися до error, коли з'єднання встановлено. Система типів забезпечує обмеження стейт-машини.

Branded Types для доменної безпеки#

Ще один патерн, який я вважаю незамінним — використання branded types для запобігання змішуванню значень, які мають один і той самий базовий тип:

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); // Error! OrderId is not assignable to UserId

І UserId, і OrderId — це рядки під час виконання. Але під час компіляції це різні типи. Ти буквально не можеш передати order ID туди, де очікується user ID. Це ловило реальні баги в кожному проєкті, де я це використовував.

Поширені підводні камені#

Просунуті типи потужні, але мають свої пастки. Ось що я вивчив на власному гіркому досвіді.

Обмеження циклічних посилань#

TypeScript має обмеження глибини рекурсії для типів (наразі приблизно 50 рівнів, хоча це варіюється). Якщо зайдеш надто глибоко, отримаєш грізну помилку «Type instantiation is excessively deep and possibly infinite».

typescript
// 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 секунди до зворотного зв'язку кожного натискання клавіші.

Тривожні ознаки:

  • Твій IDE потребує більше 2 секунд, щоб показати автодоповнення
  • tsc --noEmit помітно повільнішає після додавання типу
  • Ти бачиш помилки «Type instantiation is excessively deep»
typescript
// 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> був би достатнім
  • Тип використовується лише в одному місці, і конкретний тип був би зрозумілішим
  • Ти витрачаєш більше часу на відлагодження типу, ніж він заощадив би
  • Твоя команда не може його прочитати чи підтримувати
  • Перевірка під час виконання була б доречнішою

Використовуй їх, коли:

  • У тебе є патерн, який повторюється в багатьох типах (mapped types)
  • Тип повернення функції залежить від її вхідних даних (conditional types)
  • Ти будуєш API бібліотеки, яке має бути самодокументованим
  • Ти хочеш зробити неможливі стани непредставимими
  • Ти втомився від однієї й тієї ж категорії багів на code review
typescript
// 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 розв'язав:

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 жадібно обчислити тип замість того, щоб показувати аліас типу. Це заощадило мені годин плутанини.

Ще одна техніка — ізолюй та тестуй поступово:

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 check

Підсумок#

  • Conditional types (T extends U ? X : Y) — це if/else для типів. Стережись дистрибутивної поведінки з union.
  • infer — це патерн-матчинг — використовуй для витягнення типів зі структур на кшталт сигнатур функцій, promise та масивів.
  • Mapped types ({ [K in keyof T]: ... }) ітерують по властивостях. Комбінуй з as для ремапінгу ключів та фільтрації.
  • Template literal types маніпулюють рядками на рівні типів. У комбінації з mapped types вони неймовірно потужні для дизайну API.
  • Рекурсивні типи обробляють вкладені структури, але потребують обмежень глибини, щоб уникнути вибухів компілятора.
  • Discriminated unions з вичерпною перевіркою — це найцінніший патерн у TypeScript. Використовуй їх всюди.
  • Branded types запобігають змішуванню значень, що мають один і той самий базовий тип. Прості у реалізації, високий вплив.
  • Не переускладнюй типи. Якщо тип складніше зрозуміти, ніж баг, який він запобігає, — спрости. Мета — зробити твою кодову базу безпечнішою, а не виграти змагання з type golf.

Система типів TypeScript є Тюрінг-повною, а отже ти можеш зробити з нею майже все. Мистецтво полягає в тому, щоб знати, коли варто.

Схожі записи