Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Просунуті типи

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

14 питань

keyof T - тип-об'єднання всіх ключів типу T:

interface User {
  id: number;
  name: string;
  email: string;
}

type UserKey = keyof User;   // 'id' | 'name' | 'email'

Головне застосування - функції, що працюють з ключами об'єкта безпечно:

function getField<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user: User = { id: 1, name: 'Оля', email: 'olia@example.com' };

getField(user, 'name');    // string
getField(user, 'id');      // number
getField(user, 'phone');   // помилка: 'phone' немає в keyof User

Тип повернення T[K] точно відповідає переданому ключу - не string | number, а саме тип цього поля.

Інші застосування:

  • сортування й групування таблиць за назвою колонки: sortBy<T>(items: T[], key: keyof T);
  • перелік дозволених полів для фільтрів, форм, експорту;
  • основа mapped types: { [K in keyof T]: ... }.

Особливості:

  • з індексною сигнатурою keyof дає ширший тип. Для { [key: string]: number } результат - string | number, бо в JavaScript числові ключі об'єкта перетворюються на рядки (obj[1] - те саме, що obj['1']);
  • для масивів keyof string[] містить number і назви методів масиву ('length' | 'push' | ...);
  • лише ключі типу, а не значення: keyof працює з типами. Щоб отримати ключі значення, потрібне keyof typeof config.

Пастка з Object.keys: Object.keys(user) повертає string[], а не (keyof User)[]. Це навмисно: через структурну типізацію в об'єкті під час виконання можуть бути й інші поля, яких немає в типі. Тому for (const key of Object.keys(user)) user[key] - помилка типу, і доводиться або звужувати ключ, або використовувати Object.entries.

Докладніше в документації: Оператор keyof

У позиції типу typeof повертає тип значення (а не рядок 'object', як у JavaScript під час виконання):

const config = {
  apiUrl: '/api',
  retries: 3,
  features: { chat: true },
};

type Config = typeof config;
// { apiUrl: string; retries: number; features: { chat: boolean } }

Навіщо: значення стає джерелом правди, а тип - похідним. Додали поле в об'єкт - тип оновився сам, дублювати опис не треба.

Типові застосування:

1. Тип функції чи її частин:

function createUser(name: string, role: 'admin' | 'editor') {
  return { id: crypto.randomUUID(), name, role, createdAt: new Date() };
}

type User = ReturnType<typeof createUser>;
type CreateUserArgs = Parameters<typeof createUser>;

2. Перелік значень з масиву чи об'єкта:

const ROLES = ['admin', 'editor', 'viewer'] as const;
type Role = (typeof ROLES)[number];   // 'admin' | 'editor' | 'viewer'

const STATUS = { draft: 'Чернетка', published: 'Опубліковано' } as const;
type StatusKey = keyof typeof STATUS;   // 'draft' | 'published'

3. Тип схеми валідації: z.infer<typeof userSchema> - тип з об'єкта Zod.

4. Тип модуля чи класу: typeof import('./api'), typeof User (конструктор, а не екземпляр).

Обмеження:

  • typeof у позиції типу приймає лише ідентифікатор чи доступ до властивості (typeof config.features), але не довільний вираз: typeof fn() - помилка. Для результату виклику - ReturnType<typeof fn>;
  • розширення типів: без as const рядки й числа в об'єкті виводяться як string, number. Якщо потрібні точні літерали - as const.

Обидва typeof в одному рядку:

if (typeof value === 'string') {}   // typeof JavaScript - перевірка під час виконання
type T = typeof value;             // typeof TypeScript - тип під час компіляції

Це різні оператори з однаковою назвою: перший працює в коді, другий - лише в позиції типу.

Докладніше в документації: Оператор typeof для типів

Індексований тип доступу - отримати тип властивості іншого типу тим самим синтаксисом, що й доступ до поля об'єкта:

interface Order {
  id: number;
  status: 'new' | 'paid' | 'shipped';
  customer: { name: string; email: string };
  items: { sku: string; qty: number }[];
}

type OrderStatus = Order['status'];            // 'new' | 'paid' | 'shipped'
type Customer = Order['customer'];             // { name: string; email: string }
type CustomerEmail = Order['customer']['email'];   // string
type IdOrStatus = Order['id' | 'status'];      // number | 'new' | 'paid' | 'shipped'

T[number] - тип елемента масиву:

type OrderItem = Order['items'][number];       // { sku: string; qty: number }

const SIZES = ['sm', 'md', 'lg'] as const;
type Size = (typeof SIZES)[number];            // 'sm' | 'md' | 'lg'

number як індекс означає «будь-який числовий індекс» - тобто тип будь-якого елемента. Для кортежів - об'єднання типів усіх елементів.

Навіщо це:

  • не дублювати описи: тип статусу береться з Order, і якщо статуси зміняться, похідні типи оновляться;
  • типи для частин відповіді API: згенерований тип відповіді великий, а компоненту потрібна одна вкладена частина - ApiResponse['data']['items'][number];
  • поєднання з keyof: T[keyof T] - об'єднання типів усіх значень об'єкта.
const LABELS = { draft: 'Чернетка', published: 'Опубліковано' } as const;
type Label = (typeof LABELS)[keyof typeof LABELS];   // 'Чернетка' | 'Опубліковано'

Обмеження:

  • індекс - тип, а не значення: Order[key], де key - змінна, не працює. Потрібно Order[typeof key] або параметр типу;
  • лише існуючі ключі: Order['phone'] - помилка;
  • необов'язкове поле дає тип з undefined: для email?: string - string | undefined. Прибрати - NonNullable<User['email']>.

Різниця з доступом під час виконання: noUncheckedIndexedAccess додає | undefined до доступу до масиву за індексом у коді, але T[number] у типах повертає тип елемента без undefined.

Докладніше в документації: Indexed access types

Літеральний тип - тип, що допускає одне конкретне значення:

type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Code = 200 | 404 | 500;

function request(method: Method, url: string) {}
request('GET', '/api/users');
request('get', '/api/users');   // помилка: 'get' не входить у Method

Об'єднання літералів - основний спосіб описати скінченний набір значень у TypeScript.

Шаблонні рядкові типи (template literal types) будують рядкові типи за шаблоном - як шаблонні рядки JavaScript, але на рівні типів:

type Size = 'sm' | 'md' | 'lg';
type Color = 'red' | 'blue';

type ClassName = `btn-${Size}-${Color}`;
// 'btn-sm-red' | 'btn-sm-blue' | 'btn-md-red' | ... - усі 6 комбінацій

type EventName = `on${Capitalize<'click' | 'focus'>}`;   // 'onClick' | 'onFocus'

type UserRoute = `/users/${number}`;
const ok: UserRoute = '/users/42';
const bad: UserRoute = '/users/abc';   // помилка

Вбудовані типи для рядків: Uppercase, Lowercase, Capitalize, Uncapitalize.

Практичні застосування:

  • ключі подій і перекладів: `auth.${'login' | 'logout'}.title` - помилка в ключі ловиться компілятором;
  • CSS-значення: `${number}px` | `${number}%`;
  • ідентифікатори з префіксом: `usr_${string}`, `ord_${string}` - різні типи ID не сплутаються;
  • разом з mapped types - генерація назв методів (getName, setName) з полів.

Обмеження:

  • комбінаторний вибух: кожна позиція множить варіанти. Об'єднання на десятки тисяч рядків уповільнює компілятор, а понад 100 000 членів - помилка «Expression produces a union type that is too complex to represent»;
  • string у шаблоні (`usr_${string}`) перевіряє лише форму, а не вміст: usr_ з будь-яким продовженням підходить;
  • лише компіляція: рядки з API чи URL під час виконання не перевіряються - потрібна валідація.

Докладніше в документації: Template literal types

Параметр типу може бути обмежений іншим параметром - так описують зв'язок між аргументами функції.

function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map((item) => item[key]);
}

const users = [
  { id: 1, name: 'Оля', age: 28 },
  { id: 2, name: 'Іра', age: 31 },
];

pluck(users, 'name');    // string[]
pluck(users, 'age');     // number[]
pluck(users, 'email');   // помилка: 'email' немає серед ключів

Як це працює:

  1. TypeScript виводить T з першого аргументу - тип елементів масиву;
  2. K extends keyof T дозволяє другим аргументом лише ключі T;
  3. K виводиться як конкретний літерал ('name'), а не весь keyof T, тож тип повернення T[K][] - точний.

Інші приклади зв'язаних параметрів:

// значення має відповідати типу поля
function setField<T, K extends keyof T>(obj: T, key: K, value: T[K]): void {
  obj[key] = value;
}

setField(user, 'age', 30);       // ок
setField(user, 'age', '30');     // помилка: string не number

// групування
function groupBy<T, K extends keyof T>(items: T[], key: K): Map<T[K], T[]> {
  const groups = new Map<T[K], T[]>();
  for (const item of items) {
    const group = groups.get(item[key]) ?? [];
    group.push(item);
    groups.set(item[key], group);
  }
  return groups;
}

Типові помилки:

  • key: keyof T без окремого параметра: function getField<T>(obj: T, key: keyof T): T[keyof T] - тип повернення стає об'єднанням типів усіх полів. Окремий K зберігає зв'язок з конкретним ключем;
  • K extends string замість keyof T - тоді немає перевірки, що ключ існує;
  • символьні й числові ключі: keyof T може містити number | symbol. Якщо ключ використовується в рядкових операціях (шаблонні рядки), - K extends keyof T & string або Extract<keyof T, string>.

Цей прийом - основа багатьох бібліотек: типізовані таблиці (columnHelper.accessor('email')), форми (register('name') у React Hook Form), сховища стану з селекторами за ключем.

Докладніше в документації: Generics: параметри типу в обмеженнях

Умовний тип вибирає один з двох типів залежно від перевірки - тернарний оператор на рівні типів:

T extends U ? X : Y

«Якщо T можна присвоїти U - тип X, інакше Y».

type IsString<T> = T extends string ? true : false;

type A = IsString<'hello'>;   // true
type B = IsString<42>;        // false

Практичні приклади:

1. Тип результату залежно від аргументу:

type ApiResult<T extends 'one' | 'many'> = T extends 'one' ? User : User[];

declare function fetchUsers<T extends 'one' | 'many'>(mode: T): Promise<ApiResult<T>>;

const one = await fetchUsers('one');     // User
const many = await fetchUsers('many');   // User[]

2. Фільтрація об'єднань - так влаштовані вбудовані Exclude і Extract:

type Exclude<T, U> = T extends U ? never : T;

type Events = 'click' | 'focus' | 'keydown' | 'keyup';
type KeyEvents = Extract<Events, `key${string}`>;   // 'keydown' | 'keyup'
type Other = Exclude<Events, `key${string}`>;       // 'click' | 'focus'

never у гілці «викидає» член об'єднання.

3. NonNullable<T>, ReturnType<T>, Awaited<T> - теж умовні типи (останні два - з infer).

Особливість - розподіл по об'єднаннях: якщо перевіряється «голий» параметр типу, а передано об'єднання, умова застосовується до кожного члена окремо:

type ToArray<T> = T extends unknown ? T[] : never;
type R = ToArray<string | number>;   // string[] | number[], а не (string | number)[]

Обмеження й поради:

  • читабельність: вкладені умовні типи на кілька рівнів важко читати й підтримувати. Розбивайте на іменовані проміжні типи;
  • у реалізації функції TypeScript не може звузити умовний тип повернення за аргументом - доводиться використовувати перевантаження функції або явне приведення в реалізації;
  • глибина рекурсії обмежена: для рекурсивних умовних типів компілятор зупиняється з помилкою «Type instantiation is excessively deep».

Коли умовні типи доречні: бібліотеки й утиліти, типи API-клієнтів, похідні типи. У прикладному коді частіше вистачає об'єднань, перевантажень і узагальнень без умов.

Докладніше в документації: Умовні типи

infer оголошує змінну типу всередині умови умовного типу: «якщо T має таку форму, дістань звідти ось цю частину».

type ElementOf<T> = T extends readonly (infer U)[] ? U : never;

type A = ElementOf<string[]>;              // string
type B = ElementOf<readonly [1, 'a']>;     // 1 | 'a'
type C = ElementOf<number>;                // never - не масив

Так влаштовані вбудовані утиліти:

type ReturnType<T> = T extends (...args: any) => infer R ? R : any;
type Parameters<T> = T extends (...args: infer P) => any ? P : never;
type Awaited<T> = /* рекурсивно розгортає Promise і thenable */;

Практичні приклади:

// тип даних з функції, що повертає проміс
type Data<T> = T extends (...args: any[]) => Promise<infer D> ? D : never;
type UserData = Data<typeof fetchUser>;

// перший аргумент функції
type FirstArg<F> = F extends (first: infer A, ...rest: any[]) => any ? A : never;

// тип props React-компонента
type PropsOf<C> = C extends (props: infer P) => any ? P : never;

infer з шаблонними рядками - розбір рядкових типів:

type RouteParams<Path extends string> =
  Path extends `${string}:${infer Param}/${infer Rest}`
    ? Param | RouteParams<`/${Rest}`>
    : Path extends `${string}:${infer Param}`
      ? Param
      : never;

type P = RouteParams<'/teams/:teamId/users/:userId'>;   // 'teamId' | 'userId'

Так роутери (React Router, TanStack Router) типізують параметри маршрутів з рядка шляху.

infer з обмеженням (TS 4.7+) - дістати лише якщо підходить під тип:

type FirstString<T> = T extends [infer S extends string, ...unknown[]] ? S : never;

Що варто пам'ятати:

  • infer працює лише в частині extends умовного типу;
  • якщо структура не збіглася, спрацьовує гілка «інакше» - зазвичай never, і про це легко забути: тип мовчки стає never, а не помилкою;
  • кілька кандидатів для однієї змінної (infer U у кількох позиціях) дають об'єднання чи перетин залежно від позиції (коваріантна чи контраваріантна).

Перевага: не потрібно експортувати допоміжні типи з бібліотек - їх можна витягти з типів функцій і компонентів.

Докладніше в документації: Виведення в умовних типах

Коли умовний тип перевіряє «голий» параметр типу (T extends ..., а не T[] чи [T]), а в T передано об'єднання, умова застосовується до кожного члена окремо, і результати об'єднуються:

type ToArray<T> = T extends unknown ? T[] : never;

type A = ToArray<string | number>;
// = ToArray<string> | ToArray<number>
// = string[] | number[]

Навіщо так зроблено: на цьому побудовані фільтри об'єднань.

type Exclude<T, U> = T extends U ? never : T;
type R = Exclude<'a' | 'b' | 'c', 'a'>;   // 'b' | 'c'

Кожен член перевіряється окремо, never для відкинутих - зникає з об'єднання.

Коли розподіл заважає - потрібно перевірити об'єднання цілком:

type ToArrayAll<T> = [T] extends [unknown] ? T[] : never;

type B = ToArrayAll<string | number>;   // (string | number)[]

Загортання в кортеж ([T]) робить параметр «не голим» - розподілу немає.

Найвідоміша пастка - never:

type IsNever<T> = T extends never ? true : false;
type X = IsNever<never>;   // never, а не true!

never - порожнє об'єднання. Розподіл по порожньому об'єднанню дає порожній результат, тобто never. Правильна перевірка:

type IsNever<T> = [T] extends [never] ? true : false;
type Y = IsNever<never>;   // true

Ще приклади, де важливо розуміти розподіл:

  • boolean - це true | false, тож T extends true ? 'yes' : 'no' для boolean дасть 'yes' | 'no';
  • перевірка, чи тип є об'єднанням - на основі порівняння розподіленого й нерозподіленого результатів;
  • keyof об'єднання - лише спільні ключі, а розподільний T extends unknown ? keyof T : never - усі ключі всіх членів.

Правило: розподіл є лише тоді, коли параметр типу стоїть зліва від extends сам по собі і в нього підставлено об'єднання. T[] extends ..., [T] extends ..., Promise<T> extends ... - не розподіляються.

Докладніше в документації: Розподільні умовні типи

Mapped type проходить по ключах і будує новий тип: { [K in keyof T]: ... }. Крім типу значень, можна змінювати модифікатори й самі ключі.

Модифікатори readonly і ? - додати (+, можна опускати) чи прибрати (-):

type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Concrete<T> = { [K in keyof T]-?: T[K] };   // усі поля обов'язкові

type Locked = { readonly id: number; name?: string };
type M = Mutable<Locked>;    // { id: number; name?: string }
type C = Concrete<Locked>;   // { readonly id: number; name: string }

Вбудовані Partial, Required, Readonly - саме такі типи.

Перейменування ключів через as (TS 4.1+):

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type UserGetters = Getters<{ name: string; age: number }>;
// { getName: () => string; getAge: () => number }

string & K - бо ключі можуть бути number | symbol, а Capitalize працює лише з рядками.

Фільтрація ключів - якщо as дає never, ключ зникає:

type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

type S = OnlyStrings<{ id: number; name: string; email: string }>;
// { name: string; email: string }

type WithoutMeta<T> = { [K in keyof T as Exclude<K, `_${string}`>]: T[K] };

Практичні застосування:

  • обробники подій з полів: { [K in keyof State as \on${Capitalize<...>}Change`]: (value: State[K]) => void }`;
  • типи форм: з моделі даних - тип помилок { [K in keyof T]?: string[] } (як помилки валідації Laravel);
  • вибір полів за типом значень: лише числові поля для сортування, лише булеві для фільтрів.

Важлива деталь: mapped type вигляду { [K in keyof T]: ... } (гомоморфний) зберігає модифікатори вихідного типу - readonly і ? переходять автоматично. Якщо перебирати не keyof T, а довільне об'єднання ([K in 'a' | 'b']), модифікатори не копіюються.

Масиви й кортежі у гомоморфних mapped types лишаються масивами й кортежами - тип застосовується до елементів, а не до методів масиву.

Докладніше в документації: Mapped types: перейменування ключів через as

Крім утиліт для об'єктів (Partial, Pick, Omit, Record), TypeScript має утиліти для функцій, промісів і керування виведенням типів.

Функції й класи:

async function fetchUser(id: number, withPosts = false) {
  return { id, name: 'Оля', posts: withPosts ? [] : undefined };
}

type Args = Parameters<typeof fetchUser>;          // [id: number, withPosts?: boolean]
type Result = ReturnType<typeof fetchUser>;        // Promise<{ ... }>
type UserData = Awaited<ReturnType<typeof fetchUser>>;   // { id: number; name: string; ... }

class Service { constructor(public url: string) {} }
type CtorArgs = ConstructorParameters<typeof Service>;   // [url: string]
type Instance = InstanceType<typeof Service>;            // Service

Awaited<T> рекурсивно розгортає проміси: Awaited<Promise<Promise<number>>> - number. Так само типізовано await і Promise.all.

Об'єднання:

  • Exclude<T, U> - прибрати з об'єднання;
  • Extract<T, U> - залишити лише відповідні;
  • NonNullable<T> - прибрати null і undefined.

NoInfer<T> (TS 5.4) - заборонити виводити параметр типу з цієї позиції:

function createSelect<T extends string>(options: T[], defaultValue: NoInfer<T>) {}

createSelect(['sm', 'md', 'lg'], 'md');   // ок
createSelect(['sm', 'md', 'lg'], 'xl');   // помилка

Без NoInfer TypeScript вивів би T з обох аргументів - як 'sm' | 'md' | 'lg' | 'xl' - і помилки не було б. NoInfer каже: тип визначають лише варіанти, а значення за замовчуванням має їм відповідати.

Рядки: Uppercase, Lowercase, Capitalize, Uncapitalize - для шаблонних рядкових типів.

ThisParameterType, OmitThisParameter - для функцій з типізованим this.

Навіщо знати утиліти:

  • похідні типи замість дублювання: тип відповіді API береться з функції запиту, тип аргументів - з функції, що їх приймає;
  • типи з бібліотек без експорту: якщо бібліотека не експортує тип опцій, Parameters<typeof libFn>[0] дістане його;
  • читабельність: стандартні назви зрозумілі всім, на відміну від власних умовних типів.

Пастка ReturnType з перевантаженими функціями: береться тип останньої сигнатури перевантаження, а не всіх. Для таких функцій похідні типи краще описати явно.

Докладніше в документації: Utility Types: NoInfer

Тип може посилатися сам на себе - так описують деревоподібні структури.

Значення JSON:

type Json =
  | string
  | number
  | boolean
  | null
  | Json[]
  | { [key: string]: Json };

const data: Json = { users: [{ id: 1, tags: ['php'], manager: null }] };
const bad: Json = { createdAt: new Date() };   // помилка: Date не є Json

Корисно для функцій, що приймають «будь-який серіалізовний об'єкт» (кеш, localStorage, postMessage) - тоді Date, Map, функції не пройдуть.

Глибокі утиліти:

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

type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

type Settings = { theme: { colors: { primary: string; accent: string } } };
const patch: DeepPartial<Settings> = { theme: { colors: { accent: '#f00' } } };

Деревоподібні дані:

type Category = { id: number; name: string; children: Category[] };
type MenuItem = { label: string; href?: string; items?: MenuItem[] };

Обмеження й пастки:

  • функції, дати, Map - T[K] extends object істинне і для них. DeepReadonly<Date> спробує обійти методи Date. Їх виключають окремими гілками: T[K] extends Function | Date | Map<any, any> ? T[K] : ...;
  • масиви в mapped types обробляються як масиви (гомоморфні типи), але кортежі й readonly-масиви інколи потребують окремої обробки;
  • глибина інстанціювання: компілятор обмежує глибину рекурсії. Для дуже глибоких чи «вибухових» типів з'являється «Type instantiation is excessively deep and possibly infinite». Хвостова рекурсія в умовних типах (TS 4.5+) дозволяє до ~1000 рівнів, а звичайна обривається значно раніше - на кількох десятках;
  • продуктивність: рекурсивні типи над великими структурами (типи відповідей API на сотні полів) помітно сповільнюють редактор. Повільна підказка в IDE - ознака, що тип надто «розумний»;
  • рекурсивні псевдоніми (type Json = ... Json[]) підтримуються з TS 3.7; раніше доводилося обходити через інтерфейси.

Порада: рекурсивні типи - для справді рекурсивних даних. Якщо рекурсія потрібна лише для «розумної» перевірки, варто зважити, чи вартий виграш у типах повільнішого редактора й незрозумілих повідомлень про помилки.

Докладніше в документації: Типи з типів

Шаблонні рядкові типи разом з mapped types дають змогу генерувати API з опису даних - і компілятор перевіряє назви, які раніше були «просто рядками».

Обробники змін для кожного поля стану:

type State = { name: string; age: number; subscribed: boolean };

type ChangeHandlers<T> = {
  [K in keyof T & string as `on${Capitalize<K>}Change`]: (value: T[K]) => void;
};

type Handlers = ChangeHandlers<State>;
// {
//   onNameChange: (value: string) => void;
//   onAgeChange: (value: number) => void;
//   onSubscribedChange: (value: boolean) => void;
// }

Типізований емітер подій:

type Events = {
  'order:created': { orderId: number };
  'order:paid': { orderId: number; amount: number };
  'user:logout': undefined;
};

class Emitter<E extends Record<string, unknown>> {
  on<K extends keyof E & string>(event: K, handler: (payload: E[K]) => void) { /* ... */ }
  emit<K extends keyof E & string>(event: K, payload: E[K]) { /* ... */ }
}

const bus = new Emitter<Events>();
bus.on('order:paid', ({ amount }) => {});   // amount: number
bus.emit('order:payd', { orderId: 1 });     // помилка: друкарська помилка в назві

Вибір підмножини подій за префіксом:

type OrderEvents = Extract<keyof Events, `order:${string}`>;   // 'order:created' | 'order:paid'

Ключі перекладів з вкладеного об'єкта:

type Paths<T> = {
  [K in keyof T & string]: T[K] extends object ? `${K}.${Paths<T[K]>}` : K;
}[keyof T & string];

const uk = { auth: { login: 'Увійти', logout: 'Вийти' }, nav: { home: 'Головна' } };
type Key = Paths<typeof uk>;   // 'auth.login' | 'auth.logout' | 'nav.home'

declare function t(key: Key): string;
t('auth.login');    // ок
t('auth.lgoin');    // помилка

Прийом { ... }[keyof T] - обчислити mapped type і взяти об'єднання всіх значень.

Де межа корисності:

  • розмір об'єднань: сотні ключів перекладів на кілька рівнів вкладеності - сповільнення компілятора й редактора. Бібліотеки i18n часто генерують такі типи окремим кроком збирання;
  • повідомлення про помилки для складних типів бувають нечитабельними - варто мати прості іменовані проміжні типи;
  • динамічні ключі (зібрані з даних під час виконання) не перевіряються - знадобиться приведення типу чи валідація.

Де це приносить найбільшу користь: ключі перекладів, назви подій і каналів, маршрути, CSS-змінні дизайн-системи - рядки, помилки в яких інакше знаходяться лише під час виконання.

Докладніше в документації: Вбудовані типи для рядків

Варіантність описує, як підтипи узагальненого типу пов'язані з підтипами його параметра. Нехай Dog - підтип Animal.

  • Коваріантність (як у «виробника» значень): Producer<Dog> можна використати там, де очікується Producer<Animal>. Повернений Dog - теж Animal;
  • контраваріантність (як у «споживача»): навпаки, Consumer<Animal> підходить замість Consumer<Dog>. Функція, що вміє обробити будь-яку тварину, обробить і собаку;
  • інваріантність - коли тип і читається, і записується: жоден з варіантів не безпечний.

Параметри функцій - контраваріантні (з strictFunctionTypes, що входить у strict):

interface Animal { name: string }
interface Dog extends Animal { bark(): void }

type Handler = (animal: Animal) => void;
const dogHandler = (dog: Dog) => dog.bark();

const h: Handler = dogHandler;   // помилка: обробнику передадуть кота, а він викличе bark()

Але методи - біваріантні, і це свідома діра в системі типів:

interface WithMethod { handle(animal: Animal): void }      // синтаксис методу
interface WithProperty { handle: (animal: Animal) => void } // властивість-функція

const m: WithMethod = { handle: dogHandler };     // компілюється!
const p: WithProperty = { handle: dogHandler };   // помилка

strictFunctionTypes перевіряє лише типи, оголошені як функції-властивості. Методи лишено біваріантними для сумісності з величезною кількістю наявного коду (наприклад, Array<T>.push(item: T) - метод, і без біваріантності Dog[] не був би сумісний з Animal[]).

Масиви коваріантні - і через це несоундні:

const dogs: Dog[] = [];
const animals: Animal[] = dogs;          // дозволено
animals.push({ name: 'Мурка' });         // кіт потрапив у масив собак
dogs[0].bark();                          // падіння під час виконання

readonly Animal[] прибирає проблему: в нього не можна записувати.

Анотації варіантності (TS 4.7+) - явно вказати варіантність параметра:

interface Producer<out T> { get(): T }
interface Consumer<in T> { accept: (value: T) => void }
interface State<in out T> { get(): T; set: (v: T) => void }

Вони прискорюють перевірку великих узагальнених типів і перевіряють, що оголошення відповідає заявленій варіантності. Але через біваріантність методів анотація out T поряд з методом set(v: T) помилки не дасть - лише з властивістю-функцією.

Практичні висновки: у власних інтерфейсах для колбеків надавати перевагу синтаксису властивості (onChange: (v: T) => void) - отримаєте строгішу перевірку; для параметрів, які функція лише читає, - readonly масиви.

Докладніше в документації: Анотації варіантності

За замовчуванням TypeScript виводить параметри типу розширено: літерали стають string, масиви - звичайними масивами.

function routes<T extends readonly string[]>(paths: T): T {
  return paths;
}

const r = routes(['/home', '/about']);   // string[] - назви маршрутів втрачено

Раніше викликач мусив писати as const на кожному виклику: routes(['/home', '/about'] as const).

Константний параметр типу (TS 5.0) переносить цю вимогу в оголошення функції:

function routes<const T extends readonly string[]>(paths: T): T {
  return paths;
}

const r = routes(['/home', '/about']);   // readonly ['/home', '/about']
type Route = (typeof r)[number];          // '/home' | '/about'

Аргумент виводиться так, ніби його записано з as const.

Важлива деталь - readonly в обмеженні. Якщо обмеження - змінюваний масив (T extends string[]), const не допоможе: readonly-кортеж не можна присвоїти string[], і TypeScript відкотиться до string[]. Тому обмеження пишуть як readonly unknown[].

Де корисно:

  • конфігурації й реєстри: маршрути, ключі подій, назви полів форм, налаштування таблиць - тип будується з літералів, переданих викликачем;
  • builder-API: defineConfig({ ... }), createRoutes([...]), createStore({ ... }) - бібліотеки отримують точні типи без as const у користувача.

Інші способи керувати виведенням:

  • NoInfer<T> (TS 5.4) - заборонити виводити T з певного аргументу, щоб той лише перевірявся;
  • обмеження з літералами: T extends 'sm' | 'md' | 'lg' - тоді T виводиться як конкретний літерал, а не string;
  • порядок аргументів: TypeScript виводить параметри зліва направо, і колбеки з неанотованими параметрами отримують типи від попередніх аргументів. Тому функції на кшталт useQuery(key, fetcher) чи pipe(value, ...fns) проєктують так, щоб «джерело» типу йшло першим;
  • явні аргументи типу fn<User>(...) - останній варіант: TypeScript не підтримує часткового виведення, тож явно доведеться вказати всі параметри без значень за замовчуванням.

Пастка: const не робить значення незмінним під час виконання - лише впливає на виведений тип (з readonly в ньому).

Докладніше в документації: TypeScript 5.0: const-параметри типу