Раздел 4 · TypeScript

Мост и странности TypeScript

junior~80 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Мост и странности TypeScript

Раздел начинается выше базового уровня. Базовый TypeScript, переменные, функции, массивы и кортежи, union и intersection, классы и интерфейсы, краткое введение в дженерики, я в своё время написал бесплатно на code-basics.com/ru/languages/typescript. Если ты ещё не прошёл этот курс, остановись и пройди прежде, чем читать дальше. В BatSchool мы не пересказываем синтаксис заново. Мы строим над ним программирование на типах, разбираем странности структурной типизации и учимся вытаскивать из языка реальную надёжность на границах системы.

Этот первый урок служит мостом. Сначала договоримся о словаре: что такое тип, что такое система типов и какие задачи она решает. Потом короткий список самопроверки, чтобы убедиться, что под ногами твёрдый пол. Дальше четыре пары, каждая из которых задаёт идею “тип это значение в типовом мире”: три типа, которые надо знать на зубок, type против interface, extends против implements, объектные возможности языка. Дальше странности, ради которых менти ломают голову на собеседованиях: структурная типизация, узкие литералы и расширенные, проверка лишних свойств, вариантности параметров. После этого отдельный разворот про источники несостоятельности TypeScript: фронтальный список дыр в системе типов, чтобы дальше по курсу читать код без иллюзий. Закрываем урок практическим tsconfig.json и культурой работы с директивами компилятора.

Серия дальше идёт глубже. Урок 02 разворачивает программирование на типах (главы 1-4 курса type-level-typescript Габриэля Веньо это основа), урок 03 заходит в условные, рекурсивные и шаблонные типы, урок 04 переключается на надёжность на границах (zod, branded-типы, Result, Railway-Oriented Programming), урок 05 закрывает раздел библиотекой Effect-TS как стандартной библиотекой функционального TypeScript.

1. Что такое тип и система типов

Прежде чем разбирать странности TypeScript, договоримся о словаре. Эти определения часто опускают как “очевидные”, и зря: именно из них следует, почему TypeScript ведёт себя так, как ведёт, и где у его обещаний дыры.

Тип это классификация данных, которая определяет две вещи сразу:

  • множество допустимых значений (number это все числа JavaScript, boolean это true и false, литерал 'idle' это ровно одно значение 'idle', объединение 'idle' | 'ready' это два значения);
  • множество допустимых операций над этими значениями (для number арифметика и методы Math.*, для string конкатенация и методы вроде .toUpperCase(), для функции это вызов с конкретной сигнатурой).

Без второй части определение неполное. Тип это не просто метка “сюда пойдут такие значения”; это контракт, который говорит, что с этими значениями вообще законно делать.

Система типов это набор правил, по которым язык назначает типы конструкциям программы (выражениям, переменным, функциям, полям) и проверяет согласованность между ними. У статической системы проверка происходит до запуска программы, у динамической в момент операции.

Любая система типов решает четыре задачи в разных пропорциях.

  • Безопасность (safety). Гарантия, что определённые классы ошибок не случатся в работающей программе. Классическая формулировка Робина Милнера: “well-typed programs don’t go wrong”. В TypeScript это, например, “вызов несуществующего метода ловится компилятором, а не падает в проде на пользователе”.
  • Абстракция. Тип скрывает детали реализации за интерфейсом. Вызывая array.push(x), ты не задумываешься, как массив устроен внутри; всё, что нужно знать, описано в типе Array<T>. Чем богаче система типов, тем больше абстракций можно описывать без утечки реализации.
  • Документация. Сигнатура функции это машинно-проверяемая спецификация. Если в коде стоит (user: User) => Result<Order, OrderError>, ты знаешь, что туда подать и что получить, без открытия исходника. И эта документация не врёт: компилятор её сверяет на каждой сборке.
  • Оптимизация. Зная типы, компилятор генерит более эффективный код: убирает диспатч по тегу, инлайнит, выбирает правильное представление. TypeScript на этом этапе ничего не делает (он стирает типы в обычный JS), но другие языки с богатой системой типов (Rust, Haskell, OCaml) выжимают из неё реальные секунды.

TypeScript силён в трёх первых задачах и не тратится на четвёртую. Дальше по курсу мы будем именно про это: где TS даёт настоящую безопасность, где помогает абстракции, где играет роль документации, и где у этих гарантий есть дыры. На дыры мы выйдем в секции 10 этого же урока.

2. Где запустить пример

Любой фрагмент из урока удобно проверить в одном из трёх мест.

# 1) TypeScript Playground в браузере (на май 2026 это TS 6.0)
#    https://www.typescriptlang.org/play

# 2) tsx (без конфига), если установлен Node 24+
$ pnpm dlx tsx scratch.ts

# 3) ts-node для проектов с tsconfig
$ pnpm dlx ts-node --transpile-only scratch.ts

В Playground удобно тыкать вкладки Errors и JS справа: видно сгенерированный JS и список диагностики синхронно. В этом разделе большая часть примеров без побочек. Нам интересна реакция компилятора, не вывод программы.

Краткая справка по версиям на момент урока. TS 6.0 вышел в марте 2026 и стал последним релизом на старом JS-кодбейсе компилятора. Параллельно идёт бета TS 7.0 с нативным портом на Go (репозиторий microsoft/typescript-go, бинарник называется tsgo, поставляется через @typescript/native-preview). Заявленная цель TS 7 это десятикратное ускорение проверки типов на больших проектах. Для уроков раздела ничего не меняется, типовая модель та же. Если интересно потрогать, можно поставить рядом и проверить им свой проект:

$ pnpm dlx @typescript/native-preview tsgo --noEmit

Главное в TS 6.0 для нас три факта. Лимит совместимости поднят: минимальный target теперь ES2015, target: es5 помечен как deprecated. Изменились умолчания: strict теперь true без явной настройки, module по умолчанию esnext. Добавлен флаг --stableTypeOrdering, чтобы не было расхождений с будущим TS 7.0.

Четвёртое место прямо здесь, на странице. Ниже редактор с задачей на пять минут: функция hello(name) возвращает строку вида Hello, <name>!. Кнопка запуска прогонит скрытые тесты, среди них пустое имя и кириллица, прямо в браузере: ставить ничего не нужно, и работает это даже без сети. Дальше в разделе задачи будут серьёзнее.

3. Самопроверка после code-basics

Перед уроком 02 убедись, что эти восемь пунктов ты понимаешь без подсказки. Если по двум-трём колеблешься, вернись на code-basics, прежде чем идти дальше.

понимаюготово

Если все пять прошёл без напряжения, можно идти дальше.

4. any, unknown, never

Это три специальных типа, и их часто путают. Запомни топологию.

//  ↑ верхушка иерархии (всё снизу совместимо туда)
//  any        unknown
//  ↑           ↑
//  string  number  boolean  ...   обычные типы
//  ↑           ↑
//  'hi'    42    true   ...        литералы как подтипы
//  ↑           ↑
//  never                            пустой тип, дно иерархии

any это побег из системы типов. Любое значение типа any присваивается куда угодно и обратно. Любая операция над any снова возвращает any. Это заразный тип, и команда TypeScript прямо пишет в документации: используй только тогда, когда по-другому никак.

function trustMe(x: any) {
  // компилятор молчит, пока ты не выстрелишь себе в ногу
  return x.foo.bar.baz();
}

unknown это безопасный any. Сюда тоже можно положить что угодно, но достать без сужения нельзя.

function safe(x: unknown) {
  // x.toUpperCase()    // ошибка: нельзя вызывать неизвестное
  if (typeof x === 'string') {
    return x.toUpperCase(); // ок, x уже сужен до string
  }
  return null;
}

unknown это правильный тип на границе системы. Входной JSON, чужая библиотека без типов, JSON.parse, который в старых тайпингах возвращает ровно его. Идея “парси, не валидируй” из урока 02-cs/06 строится именно вокруг этого: пришёл unknown, ушёл WeatherRecord или ошибка.

never это пустой тип. В него нельзя положить ничего. Используется в двух ролях: “функция не возвращается” (падает или висит) и “эта ветка невозможна”.

function bail(message: string): never {
  throw new Error(message);
}

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number };

function area(s: Shape): number {
  switch (s.kind) {
    case 'circle': return Math.PI * s.radius ** 2;
    case 'square': return s.side ** 2;
    default: {
      const _exhaustive: never = s; // ловит пропущенные ветки
      return _exhaustive;
    }
  }
}

Если в Shape появится triangle, компилятор тут же скажет, что s уже не never, и выдаст ошибку на этой строке. Это первая проверка, которую даёт TypeScript. И она не вместо валидации входных данных, а в дополнение.

Канонический хелпер вместо местного _exhaustive:

export function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}

assertNever спасает там, где switch нельзя превратить в выражение: мутабельный построитель, ранние return, побочные эффекты по веткам. Связь с размеченным объединением из 03-js/03 прямая. Дискриминирующее поле kind поднимает сужение.

5. type против interface

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

Слияние объявлений. interface сливается, если объявлен несколько раз. type нет.

interface Window {
  myFlag: boolean;
}
interface Window {
  myFlag2: number; // оба попадают в Window
}

type Foo = { a: number };
// type Foo = { b: string };  // ошибка: дубликат

Это нужно для расширения чужих модулей. Дописать NodeJS.ProcessEnv или Window без форка типов.

Что они умеют. type поддерживает объединения, пересечения, mapped, conditional, шаблонные литералы, infer. interface тоже умеет наследование и пересечение через extends, но не умеет объединение и mapped напрямую.

type Status = 'idle' | 'loading' | 'ready'; // только type
type WithId<T> = T & { id: string };        // пересечение в type

Скорость компиляции. На больших иерархиях interface extends проверяется быстрее, чем эквивалентное type & .... Команда TypeScript сама использует interface для публичных контрактов в lib.dom.d.ts. На маленьких типах разница незаметна.

Практическое правило: interface для публичных контрактов и форм, которые могут когда-нибудь нуждаться в слиянии; type для алиасов, объединений, mapped, conditional. Если сомневаешься, начинай с type. Перейти потом не больно.

6. extends против implements

Эти два ключевых слова часто путают, потому что выглядят похоже, но делают разные вещи.

extends это наследование. Поля и методы родителя попадают в потомка, включая реализацию.

class Animal { name = ''; }
class Cat extends Animal { meow() { return 'meow'; } }
new Cat().name; // унаследовано

implements это контракт. Класс обязан иметь все свойства и методы из интерфейса или типа, но никакой реализации не наследует.

interface Fed { feed(): void; }
class Cat implements Fed { feed() {} } // обязан определить feed

Можно объявлять интерфейсы для будущих классов и оставлять им свободу реализации:

interface Repo<T> {
  find(id: string): Promise<T | null>;
  save(entity: T): Promise<void>;
}
class PostgresRepo<T> implements Repo<T> { /* ... */ }
class InMemoryRepo<T> implements Repo<T> { /* ... */ }

interface тоже умеет extends, и здесь это значит “наследовать декларацию”.

interface Animal { name: string }
interface Cat extends Animal { meow(): string }

type для пересечения использует амперсанд &:

type Animal = { name: string };
type Cat = Animal & { meow(): string };

Между interface extends и type & есть тонкая разница. interface extends падает на конфликте свойств (одно имя, разные типы), а type & молча смешивает их в never. Конфликты ломаются тише, и это аргумент за interface для иерархий, особенно когда иерархия открыта на расширение из библиотек.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.