Система типов TypeScript: фундамент
Синтаксис TypeScript — это JavaScript плюс аннотации. Выучить его — дело часа. Настоящая ценность и настоящая глубина языка — в системе типов. Она устроена иначе, чем в Java или C#, и понимание её принципов отличает того, кто «пишет TS», от того, кто «борется с TS». Этот файл — про ядро системы типов. Держите открытым Playground и проверяйте каждый пример.
Мысленная модель: типы — это множества значений
Самая полезная ментальная модель (её продвигает Дэн Вандеркам в Effective TypeScript): тип — это множество значений, а не «форма памяти».
boolean— множество из двух значений:{ true, false }.string— бесконечное множество всех строк.- Литеральный тип
"red"— множество из одного значения:{ "red" }. never— пустое множество. Значений нет вообще.unknown— множество всех значений (вершина).
Тогда операции над типами становятся операциями над множествами:
- Union
A | B— объединение множеств. Значение принадлежитAилиB. - Intersection
A & B— пересечение. Значение принадлежитAиB. A extends B(совместимость/подтип) — «Aявляется подмножествомB».
Эта модель объясняет неочевидные вещи. Например, почему "red" | "blue" шире, чем
"red" (объединение больше), а { a: 1 } & { b: 2 } требует оба поля
(пересечение форм объекта даёт объект со всеми полями). Возвращайтесь к ней всякий
раз, когда типы ведут себя «странно».
Структурная типизация («если крякает как утка»)
Ключевое отличие TypeScript от номинальных систем: совместимость определяется
структурой, а не именем. Тип A совместим с B, если у A есть всё, что
требует B, — независимо от того, объявлял ли кто-то связь между ними.
interface Point {
x: number;
y: number;
}
function distance(p: Point): number {
return Math.hypot(p.x, p.y);
}
// vec НЕ объявлен как Point, но структурно подходит — есть x и y
const vec = { x: 3, y: 4, z: 5 };
distance(vec); // OK: лишнее поле z не мешает
Здесь vec не «реализует» Point явно — и не должен. Раз есть нужные поля,
он подходит. Это невероятно гибко, но есть нюансы:
Excess property checks. Есть исключение: при передаче литерала объекта напрямую TypeScript ругается на лишние поля — это защита от опечаток.
distance({ x: 3, y: 4, z: 5 });
// ~~~~~ 'z' does not exist in type 'Point' (для литерала — ошибка)
Номинальность вручную (branding). Иногда структурная типизация мешает: type UserId = string и type OrderId = string взаимозаменяемы, хотя не должны быть.
Их различают «брендированием»:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
function getUser(id: UserId) { /* ... */ }
const raw = "u_123";
// getUser(raw); // ошибка: string не UserId
getUser(raw as UserId); // осознанное присвоение бренда на границе
Примитивы и литеральные типы
Базовые типы: string, number, boolean, bigint, symbol, null,
undefined. Плюс литеральные типы — конкретные значения как типы:
let dir: "up" | "down" | "left" | "right"; // только эти 4 строки
let http: 200 | 404 | 500; // только эти числа
Литеральные union — рабочая лошадка TS: они дают исчерпывающую проверку и
автодополнение там, где в других языках нужны enum. Кстати, enum в TS
существует, но у него проблемы (он генерирует рантайм-код, плохо стирается,
const enum конфликтует с изоляцией модулей). Идиоматичнее union литералов или
as const-объекты, а не enum.
any, unknown, never — три особых типа
Это три типа, путаница в которых — источник большинства ошибок новичков.
-
any— «выключить проверку типов». Значение типаanyможно присваивать куда угодно и вызывать что угодно на нём. Это дыра в системе:any«заражает» — ошибки вокруг него не ловятся. Используйтеanyтолько как осознанную временную заглушку; в strict-проекте включите правилоno-explicit-any. -
unknown— «типобезопасныйany». Тоже принимает любое значение, но ничего с ним нельзя сделать, пока не сузите тип проверкой. Это правильный тип для данных «извне»:JSON.parse, тело HTTP-запроса,catch (e).
function handle(input: unknown) {
// input.toUpperCase(); // ошибка: сначала докажи, что это строка
if (typeof input === "string") {
input.toUpperCase(); // OK: внутри ветки input сужен до string
}
}
never— пустое множество, «значения быть не может». Тип функции, которая никогда не возвращается (бросает или вечно висит), и — важнейшее применение — инструмент исчерпывающих проверок (см. discriminated unions ниже).
Правило на всю жизнь: на границах системы принимайте unknown, а не any, и
сужайте типы явно. Это заставляет валидировать внешние данные — ровно то, что
спасает от рантайм-падений.
Union и intersection на практике
// Union: значение — одно ИЗ
type Id = string | number;
// Intersection: объединяем формы (значение обладает всеми свойствами)
interface HasName { name: string }
interface HasAge { age: number }
type Person = HasName & HasAge; // нужны И name, И age
const p: Person = { name: "Аня", age: 30 };
Тонкость: intersection примитивов часто даёт never (string & number — пустое
множество, таких значений нет). Intersection же объектов сливает их поля.
Type narrowing (сужение типов)
Narrowing — то, как TypeScript отслеживает, что внутри блока тип уже, чем объявлено. Компилятор понимает поток управления (control flow analysis) и сужает тип по проверкам. Это, возможно, самая «магическая» и самая полезная часть языка. Инструменты сужения:
function describe(x: string | number | null | string[]) {
if (x === null) return "пусто"; // narrowing по равенству
if (typeof x === "number") return x.toFixed(2); // typeof-guard
if (Array.isArray(x)) return x.join(", "); // встроенный guard
return x.toUpperCase(); // здесь x точно string
}
class Dog { bark() {} }
class Cat { meow() {} }
function speak(a: Dog | Cat) {
if (a instanceof Dog) a.bark(); // instanceof-guard
else a.meow();
}
// Проверка наличия свойства
type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
if ("swim" in animal) animal.swim(); // in-guard
else animal.fly();
}
Пользовательские type guards (type predicates)
Когда встроенных проверок не хватает, пишут функцию-предикат с типом-возвратом
arg is Type. Компилятор будет доверять ей при сужении:
interface User {
id: string;
email: string;
}
// Возврат `x is User` — обещание компилятору: если true, то x это User
function isUser(x: unknown): x is User {
return (
typeof x === "object" &&
x !== null &&
"id" in x &&
"email" in x &&
typeof (x as Record<string, unknown>).email === "string"
);
}
function greet(data: unknown) {
if (isUser(data)) {
console.log(data.email); // data сужен до User
}
}
Type guards мощны, но опасны: компилятор верит предикату на слово. Если логика внутри неверна, вы сами создали дыру. Для сложных объектов лучше использовать библиотеку валидации (Zod), которая генерирует и проверку, и тип из одной схемы (разберём в файле про идиомы).
Discriminated unions — главный паттерн моделирования
Это самый важный практический приём системы типов. Идея: объединять варианты, у каждого из которых есть общее поле-дискриминатор с литеральным типом. По нему компилятор безошибочно сужает весь объект.
// Каждый вариант помечен полем kind (дискриминатор)
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; // shape сужен: есть radius
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
default:
// Исчерпывающая проверка: если добавить новый kind и забыть case,
// shape здесь НЕ будет never — и компилятор укажет на ошибку.
return assertNever(shape);
}
}
function assertNever(x: never): never {
throw new Error(`Необработанный вариант: ${JSON.stringify(x)}`);
}
Магия в assertNever. В блоке default все известные kind уже разобраны,
поэтому shape имеет тип never. Функция принимает только never. Стоит
добавить в Shape четвёртый вариант и забыть про него — shape в default
перестанет быть never, и компилятор выдаст ошибку. Это превращает
«добавил вариант, забыл обработать» из рантайм-бага в ошибку компиляции.
Моделируйте состояния приложения именно так: ответы API (loading | success | error), события, доменные сущности.
Обобщения (generics)
Generics — параметризация типов. Они позволяют писать код, работающий с любым
типом, не теряя информацию о нём (в отличие от any).
// T — параметр типа. Возвращаемый тип связан с входным.
function first<T>(arr: readonly T[]): T | undefined {
return arr[0];
}
const n = first([1, 2, 3]); // n: number | undefined (T выведен как number)
const s = first(["a", "b"]); // s: string | undefined
Сила в связывании типов входа и выхода. first знает, что возвращает элемент
именно того типа, что и массив. Сравните с any, где эта связь теряется.
Ограничения (constraints)
T extends ... ограничивает, какие типы допустимы, — и внутри функции даёт доступ
к гарантированным свойствам:
// K обязан быть ключом объекта T — иначе доступ был бы небезопасен
function prop<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Ли", age: 25 };
const name = prop(user, "name"); // name: string
// prop(user, "email"); // ошибка: "email" не keyof user
Здесь работают два оператора типового уровня: keyof T даёт union ключей объекта
("name" | "age"), а T[K] — indexed access, тип значения по ключу. Их
комбинация — фундамент типобезопасной работы с объектами.
Утилитные типы
TypeScript поставляет набор встроенных «функций над типами» — их надо знать наизусть, они экономят массу кода:
interface Todo {
id: string;
title: string;
done: boolean;
}
type PartialTodo = Partial<Todo>; // все поля опциональны
type RequiredTodo = Required<Todo>; // все обязательны
type ReadonlyTodo = Readonly<Todo>; // все readonly
type TodoPreview = Pick<Todo, "id" | "title">; // только выбранные поля
type TodoNoId = Omit<Todo, "id">; // все, кроме указанных
type TodoMap = Record<string, Todo>; // { [ключ: string]: Todo }
type Raw = string | null | undefined;
type Clean = NonNullable<Raw>; // string
// Работа с функциями
type Fn = (a: number, b: string) => boolean;
type R = ReturnType<Fn>; // boolean
type P = Parameters<Fn>; // [number, string]
Partial, Pick, Omit, Record, ReturnType встречаются в реальном коде
ежедневно. Полный список — в
Handbook: Utility Types.
В следующем файле мы увидим, как они реализованы через mapped и conditional types,
и научимся писать свои.
interface против type
Оба описывают форму объекта, и в 90% случаев взаимозаменяемы. Различия:
interfaceможно расширять (extends) и дополнять (declaration merging — несколько объявлений сливаются). Обычно предпочтителен для публичных API-объектов и когда важна расширяемость.type— псевдоним для любого типа: union, intersection, кортеж, примитив, условный тип. ТолькоtypeумеетA | B,keyof, mapped types.
Практическое правило: interface для форм объектов, type для всего
остального (unions, функций, утилит). Не тратьте время на споры — важнее
согласованность внутри проекта.
readonly, кортежи и as const
const point: readonly [number, number] = [1, 2]; // кортеж фиксированной формы
// point[0] = 5; // ошибка: readonly
// as const делает литерал максимально узким и глубоко readonly
const config = {
env: "prod",
retries: 3,
} as const;
// config.env: "prod" (не string), config.retries: 3 (не number), всё readonly
as const — незаменимый инструмент: он превращает изменяемый литерал в точный
неизменяемый тип. Из него удобно выводить union (type Env = typeof config.env).
Типичные ошибки новичков
- Аннотировать то, что и так выводится.
const x: number = 5— избыточно. Доверяйте выводу типов; аннотируйте границы (параметры, возвраты публичных функций), а не локальные переменные. - Тянуться к
anyпри первой же ошибке. Почти всегда есть точный тип илиunknown+ сужение.anyзаглушает не только эту ошибку, но и будущие. - Приведение через
asвместо сужения.x as User— обещание, которое компилятор не проверяет. Это осознанный обход системы, а не решение. Валидируйте. - Путать типы значений и типы уровня типов.
typeof,keyof,T[K]работают на уровне типов; их нельзя смешивать с рантайм-логикой.
Источники
- Handbook: Everyday Types и Narrowing.
- Дэн Вандеркам, Effective TypeScript — Item 7 «Think of Types as Sets of Values».
- Total TypeScript: Type Transformations — практика по generics и утилитам.
Что дальше
Мы разобрали ядро. Теперь — продвинутая часть системы типов (conditional/mapped
types, infer, template literals, satisfies), идиоматика и обработка ошибок.