Перейти к содержимому

Система типов и сужение

В краткой версии ты видел interface и type в действии — объявляли формы объектов, объединения литералов, обязательные и необязательные поля. Теперь опустимся на уровень ниже: разберёмся, как TypeScript вообще решает, совместимы ли два типа. Это знание — фундамент. Без него каждое «почему тут ошибка, а тут нет» превращается в гадание, а с ним — в логический вывод.

И это не академический вопрос. В продакшене структурная типизация — постоянный источник и сюрпризов, и элегантных решений. Она позволяет передавать «чужие» объекты в функцию, о которой ты думал, что она принимает только «свои», ломает моки в тестах и одновременно даёт то самое утиное поведение, за которое мы любим динамические языки. Плюс сужение — механизм, который заставляет компилятор делать за тебя половину работы по валидации.

Структурная типизация против номинальной

Заголовок раздела «Структурная типизация против номинальной»

В номинальных системах (Java, C#, Rust) типы равны, если имеют одно имя. Объявил class User и class Customer с одинаковыми полями — это разные типы, присвоить один другому нельзя. TypeScript работает иначе: совместимость определяется структурой (shape), а не именем. Если у объекта есть все нужные поля нужных типов — он подходит, как бы он ни назывался. Формальные правила этой совместимости описаны в TypeScript Handbook: Type Compatibility — стоит прочитать, когда начнёшь ловить «невозможные» ошибки присвоения.

interface User {
id: number;
name: string;
}
// Нигде не писали "implements User" — и не надо
const fromApi = JSON.parse('{"id": 1, "name": "Влад"}');
function greet(user: User): string {
return `Привет, ${user.name}!`;
}
greet(fromApi); // ок: у объекта есть id и name — форма совпадает

Это проектное решение Microsoft: TypeScript должен был типизировать существующий JavaScript-код, где «классов» в современном смысле почти не было — были объекты-литералы. Структурная типизация позволила накладывать типы на код, не переписывая его.

Обрати внимание на обратную сторону — лишние поля не мешают:

interface User {
id: number;
name: string;
}
const admin = {
id: 1,
name: 'Влад',
role: 'admin', // лишнее поле
};
greet(admin); // ок: admin «шире» User — у него есть всё, что нужно

А вот при прямом присвоении объекта-литерала компилятор споткнётся — это флажок «freshness check» (проверка свежести), срабатывающий только на литералы:

greet({ id: 1, name: 'Влад', role: 'admin' });
// Ошибка: объектный литерал может указывать только известные свойства.
// С переменной admin выше — нормально, с литералом — ошибка.

Свежесть — защита от опечаток (nmae вместо name молча прошло бы, будь лишние поля разрешены).

Примитивы: string, number, boolean, null, undefined, symbol, bigint, void (возврат из функции без значения), never (значение которого не существует), unknown (неизвестное — безопасная альтернатива any) и object. Из них строятся:

  • массивы: string[], Array<number>;
  • кортежи: [string, number] — фиксированная длина и позиции;
  • функции: (x: number) => string;
  • объекты: { host: string; port?: number };
  • литеральные типы: 'production', 200, true — подтипы своих примитивов.

Ключевая мысль про литеральные типы: 'production' — это подтип string. Отсюда вывод типов в самом примитивном виде:

let mode = 'production'; // выводится string — переменная может меняться
const env = 'production'; // выводится 'production' — const не может измениться

Этим механизмом мы широко воспользуемся в главе про as const.

И про unknown: если значение пришло извне — парсинг JSON, fetch, query-параметры — единственный честный тип для него — unknown. any обещает компилятору «доверься мне, там всё хорошо» и отключает проверку; unknown заставляет сузить тип до использования.

Сужение — процесс, когда компилятор на основании проверок в коде уменьшает тип переменной. Это главный механизм работы с union-ами, и ему посвящена отдельная глава TypeScript Handbook: Narrowing — там собраны все способы сужения, включая те, что мы здесь не разбираем (например, in с правосторонним literal и сужение по Array.isArray).

Для примитивов работает, как ожидаешь, с одной ловушкой: typeof null'object'. Поэтому порядок проверок важен:

function format(value: string | number | null): string {
if (typeof value === 'string') {
return value.toUpperCase(); // string
}
if (typeof value === 'number') {
return value.toFixed(2); // number
}
// сюда дошли только при null
return '—'; // value: null
}

Для классов и большинства встроенных объектов (Date, Error, Array, Map):

function describe(error: Error | TypeError): string {
if (error instanceof TypeError) {
return `TypeError: ${error.message}`;
}
return error.message; // Error
}

Проверяет наличие свойства в объекте и одновременно сужает union по нему:

interface Success {
ok: true;
data: string;
}
interface Failure {
ok: false;
error: string;
}
function handle(result: Success | Failure): string {
if ('data' in result) {
return result.data; // Success: у него есть data
}
return result.error; // Failure
}

Пользовательские предикаты типов (type predicates)

Заголовок раздела «Пользовательские предикаты типов (type predicates)»

Когда сужение не выводится автоматически, ты описываешь его сам — функция, возвращающая value is T:

interface User {
id: number;
name: string;
}
// Сигнатура говорит компилятору: если вернула true, value — это User
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
typeof (value as User).id === 'number' &&
typeof (value as User).name === 'string'
);
}
const raw: unknown = JSON.parse('{"id": 1, "name": "Влад"}');
if (isUser(raw)) {
console.log(raw.name); // raw: User — сужение сработало
}

Это единственный «официальный» способ сказать компилятору «я проверил, доверься». Синтаксис value is T и правила его применения закреплены в TypeScript Handbook: Narrowing, раздел type predicates. В проде такие предикаты пишутся поверх zod/io-ts схем, ручная валидация — тренировочный вариант.

Утверждение assert value is T бросает исключение, если проверка не прошла, и сужает тип вызывающему коду:

function assertIsUser(value: unknown): asserts value is User {
if (!isUser(value)) {
throw new Error('Ожидался User, получено: ' + JSON.stringify(value));
}
}
const raw: unknown = JSON.parse('{}');
assertIsUser(raw); // после этой строки raw: User
console.log(raw.name);

А теперь центральная конструкция — дискриминированное объединение: union объектов с общим литеральным полем-тегом. Сужение по тегу работает и по in, и по сравнению:

type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rect'; 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 'rect':
return shape.width * shape.height;
case 'triangle':
return (shape.base * shape.height) / 2;
}
}

Заметь: в switch выше нет default. И код компилируется. Как? После обработки всех трёх веток TypeScript выводит тип оставшегося shape — и получает never. never — это пустое множество значений: «такого значения не существует». Если ты добавишь четвёртую фигуру (kind: 'ellipse') и не допишешь ветку — компилятор покажет ошибку в каждом месте, где shape используется после switch, потому что тип уже не never.

Это даёт железобетонный паттерн исчерпывающей проверки:

function assertNever(value: never): never {
throw new Error(`Необработанный вариант: ${JSON.stringify(value)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rect':
return shape.width * shape.height;
case 'triangle':
return (shape.base * shape.height) / 2;
default:
return assertNever(shape); // shape: never — если сюда попали, забыли ветку
}
}

Теперь добавление нового варианта в Shape превратится в ошибку компиляции в каждом месте, где union обрабатывается. Рефакторинг по всему проекту — rename и добавление ветки. Именно так строятся обработчики событий, редьюсеры, парсеры команд.

Формально почти всё, что объявляется через interface, можно объявить через type, и наоборот — кроме объединений. В спорных случаях выручает TypeScript Handbook: Everyday Types — там же разобраны литеральные типы и keyof. Но в команде нужна конвенция, и она опирается на различия:

Критерий interface type
Расширение extends, плюс merge объявлений пересечение &
Объединения (|) нет да
Примитивы, кортежи, функции нет да
Merge двух объявлений в одном scope да ошибка «Duplicate identifier»

Declaration merging — нишевый, но важный кейс: библиотеки и плагины расширяют глобальные типы, объявляя интерфейс повторно:

plugin-auth/index.d.ts
interface Session {
userId: string;
}
// plugin-admin/index.d.ts — другой файл той же библиотеки
interface Session {
role: 'admin' | 'user';
}
// В итоге Session = { userId: string; role: 'admin' | 'user' }

Рабочая конвенция, которую ты встретишь в большинстве серьёзных кодбейзов: interface — для форм объектов и публичных контрактов (сущности, props, DTO), type — для объединений, кортежей, вычисляемых и функциональных типов. Пересечение типов (&) при этом — источник граблей, см. раздел ниже.

1. Пересечение несовместимых примитивов — never молча.

type A = { name: string };
type B = { name: number };
type C = A & B;
declare const c: C;
c.name; // never — string & number не существует, ошибки при объявлении нет

Компилятор не запрещает объявить невозможный тип. Он проявится на первом использовании. На легаси-коде пересечения из внешних библиотек — классический источник странных never в автодополнении.

2. Доверие к typeof для массивов.

typeof arr в рантайме возвращает 'object', не 'array'. Проверка typeof x === 'array' — всегда false. Для массивов: Array.isArray(x) (и он же сужает тип корректно).

3. Предикаты-лжецы.

Функция с сигнатурой value is T, которая на самом деле не проверяет всё — ложное чувство безопасности:

// ПЛОХО: ничего не доказывает, но компилятор доверился
function isUser(v: unknown): v is User {
return typeof v === 'object' && v !== null;
}

Если пишешь предикат руками — проверяй каждое поле. В проде бери zod и выводи тип через z.infer<typeof schema>.

4. «Freshness check» в моках.

const mock = { id: 1, name: 'x', extra: true };
greet(mock); // ок
greet({ id: 1, name: 'x', extra: true }); // ошибка — литерал

Если лишнее поле нужно в тесте — объяви переменную. Если в проде лезет лишнее поле из API — проблема на стороне контракта, а не в литерале.

5. Отсутствие тега в union — сужение не работает.

// ПЛОХО: общее поле не литерального типа
type Result = { success: boolean; data?: string; error?: string };
// ХОРОШО: тег — литерал, сужение работает
type Result =
| { success: true; data: string }
| { success: false; error: string };

С первым вариантом после if (result.success) поля data и error оба остаются string | undefined — проверка ничего не даёт.

6. Утверждения as вместо сужения.

const value = JSON.parse(input) as User; // компилятор промолчал на любом мусоре

as — это «я знаю лучше компилятора». Иногда это правда (бренды, DOM), но в 90% случаев вместо as нужен предикат или схема-валидация.

1. Чем структурная типизация отличается от номинальной? Плюсы и минусы.

Структурная определяет совместимость по форме (наличию нужных полей), номинальная — по имени типа. Плюс структурной: гибкость и типизация существующего JS-кода без наследования; утиная типизация. Минусы: невозможно «запретить» структурно совместимые типы (отсюда branded types), меньше защиты на границах домена.

2. Что такое never и где он появляется?

Тип пустого множества значений. Возникает: в исчерпывающих проверках после обработки всех вариантов union, при несовместимом пересечении примитивов, в функциях, которые никогда не возвращаются (throw/process.exit). Используется для проверки полноты обработки union-ов через assertNever.

3. Чем unknown отличается от any?

any отключает проверки — любое использование разрешено. unknown — верхний тип, с которым нельзя ничего делать без предварительного сужения (typeof, предикаты, схемы). Любое значение присваивается в оба, но из unknown достать значение можно только через проверку. Правило: входящие данные — всегда unknown.

4. Как работает сужение через in и почему тег должен быть литеральным типом?

in проверяет наличие свойства; компилятор отсекает из union ветки без этого свойства. Тег вида kind: 'circle' — литерал; сравнение shape.kind === 'circle' отсекает все остальные ветки однозначно. Тег вида kind: string не сужает — любая строка совместима с любой веткой.

5. interface vs type — что выбрать и почему?

Для форм объектов и публичных контрактов — interface: читаемость, extends, declaration merging для плагинов. Для объединений, кортежей, функций и вычисляемых типов — только type. Обе конструкции в одном проекте — норма, главное — консистентность по правилу выше.

6. Что такое freshness check и когда он срабатывает?

Проверка избыточных свойств объекта-литерала при прямом присвоении аннотированной переменной/аргументу. Срабатывает только на литералах, не на переменных. Защищает от опечаток в ключах. Обходится через промежуточную переменную.

7. Как устроен type predicate и чем он отличается от asserts?

value is T — функция возвращает boolean; после if (fn(x)) в true-ветке тип x сужается. asserts value is T — функция ничего не возвращает в успехе и кидает исключение в провале; сужение действует сразу после вызова, без if.

  1. Опиши тип Result<T, E> через дискриминированный union и напиши функцию unwrap(result), которая возвращает T или бросает E, с assertNever в конце switch. Добавь третий вариант в union — убедись, что компилятор требует ветку.
  2. Напиши предикат isNonEmptyArray<T>(value: unknown): value is [T, ...T[]] — тип «массив с хотя бы одним элементом», где обращение arr[0] не даёт T | undefined. Проверь, что обычный массив после провеки корректно сужается.
  3. Возьми JSON ответа любого публичного API (GitHub, JSONPlaceholder), объяви переменную с типом unknown, напиши предикат isRepo(value): value is Repo с проверкой всех полей и достань данные.
  4. Рефакторинг: найди в своём коде union вида { success: boolean; ... } и перепиши на дискриминированный с литеральным тегом; зафиксируй, какие места потребовали правок после сужения.
  5. Напиши функцию assertUnreachable(x: never): never и примени её в обработчике redux-действий или команд CLI-парсера; добавь новое действие и убедись, что типы указали на все пропущенные места.

Критерий результата: код компилируется со strict: true, каждое сужение доказуемо (предикат проверяет все поля), добавление варианта в union вызывает ошибку компиляции в каждом обработчике.