TypeScript Система типов TypeScript: фундамент
0%

Система типов TypeScript: фундамент

Система типов 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] работают на уровне типов; их нельзя смешивать с рантайм-логикой.

Источники

Что дальше

Мы разобрали ядро. Теперь — продвинутая часть системы типов (conditional/mapped types, infer, template literals, satisfies), идиоматика и обработка ошибок.

Продвинутые типы, идиомы и ошибки

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов