Система типов и сужение
В краткой версии ты видел 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 заставляет сузить тип до использования.
Сужение (narrowing)
Заголовок раздела «Сужение (narrowing)»Сужение — процесс, когда компилятор на основании проверок в коде уменьшает тип переменной. Это главный механизм работы с 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}instanceof
Заголовок раздела «instanceof»Для классов и большинства встроенных объектов (Date, Error, Array, Map):
function describe(error: Error | TypeError): string { if (error instanceof TypeError) { return `TypeError: ${error.message}`; } return error.message; // Error}Оператор in
Заголовок раздела «Оператор in»Проверяет наличие свойства в объекте и одновременно сужает 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 — это Userfunction 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 схем, ручная валидация — тренировочный вариант.
Утверждения (assertions) и discriminated unions
Заголовок раздела «Утверждения (assertions) и discriminated unions»Утверждение 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: Userconsole.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; }}never и исчерпывающая проверка
Заголовок раздела «never и исчерпывающая проверка»Заметь: в 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 vs type: инженерная конвенция
Заголовок раздела «interface vs type: инженерная конвенция»Формально почти всё, что объявляется через interface, можно объявить через type, и наоборот — кроме объединений. В спорных случаях выручает TypeScript Handbook: Everyday Types — там же разобраны литеральные типы и keyof. Но в команде нужна конвенция, и она опирается на различия:
| Критерий | interface |
type |
|---|---|---|
| Расширение | extends, плюс merge объявлений |
пересечение & |
Объединения (|) |
нет | да |
| Примитивы, кортежи, функции | нет | да |
| Merge двух объявлений в одном scope | да | ошибка «Duplicate identifier» |
Declaration merging — нишевый, но важный кейс: библиотеки и плагины расширяют глобальные типы, объявляя интерфейс повторно:
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.
Практика
Заголовок раздела «Практика»- Опиши тип
Result<T, E>через дискриминированный union и напиши функциюunwrap(result), которая возвращаетTили бросаетE, сassertNeverв конце switch. Добавь третий вариант в union — убедись, что компилятор требует ветку. - Напиши предикат
isNonEmptyArray<T>(value: unknown): value is [T, ...T[]]— тип «массив с хотя бы одним элементом», где обращениеarr[0]не даётT | undefined. Проверь, что обычный массив после провеки корректно сужается. - Возьми JSON ответа любого публичного API (GitHub, JSONPlaceholder), объяви переменную с типом
unknown, напиши предикатisRepo(value): value is Repoс проверкой всех полей и достань данные. - Рефакторинг: найди в своём коде union вида
{ success: boolean; ... }и перепиши на дискриминированный с литеральным тегом; зафиксируй, какие места потребовали правок после сужения. - Напиши функцию
assertUnreachable(x: never): neverи примени её в обработчике redux-действий или команд CLI-парсера; добавь новое действие и убедись, что типы указали на все пропущенные места.
Критерий результата: код компилируется со strict: true, каждое сужение доказуемо (предикат проверяет все поля), добавление варианта в union вызывает ошибку компиляции в каждом обработчике.
Что почитать
Заголовок раздела «Что почитать»- TypeScript Handbook: Narrowing — все способы сужения с примерами.
- TypeScript Handbook: Type Compatibility — формальные правила структурной совместимости.
- TypeScript Deep Dive: TypeScript’s Type System — глубокий разбор системы типов.
- Marius Schulz: Typing Functions in TypeScript — функциональные типы и контекстуальная типизация.
- zod — TypeScript-first schema validation — как валидация рантайма даёт типы без предикатов руками.