Раздел 2 · Программирование / CS

Типы и ошибки: parse, не validate

middle-senior~40 мин

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

Типы и ошибки: parse, не validate

Урок 05 показал композицию и итерацию без циклов: чистые функции склеиваются в pipeline, обходим список через map/filter/reduce, читаем программу слева направо. Хорошо, но обычная программа не живёт в стерильной среде. На вход приходит сырой JSON, в форму вбивают мусор, файл на диске успели обрезать. Где-то на границе сырое надо превратить в строгое.

Сегодня мы научимся работать с этой границей через типы. Покажем, как тип превращается в множество, как сделать неправильные состояния непредставимыми, и почему лозунг Alexis King “parse, don’t validate” выгоднее обычного “сначала проверим, потом продолжим”. В финале возьмём два сквозных примера: парсер формы регистрации (с интерактивным виджетом) и парсер JSON-конфига.

Тип как множество

Тип это множество значений. boolean, два значения. number, бесконечное. 'red' | 'green' | 'blue', три значения. Чем уже множество, тем меньше неправильных состояний влезает в переменную, тем меньше нужно if-ов снаружи.

Из этой простой идеи растут две алгебраические операции над типами:

  • Product type это “и”. { name: string; age: number }, значение, и name, и age одновременно. Размер множества, произведение размеров полей.
  • Sum type это “или”. Square | Circle | Triangle, значение, ровно одна из альтернатив. Размер множества, сумма размеров альтернатив.
// product, "и"
type User = { name: string; age: number };

// sum, "или"
type Shape =
  | { kind: 'square'; side: number }
  | { kind: 'circle'; radius: number }
  | { kind: 'triangle'; base: number; height: number };

Поле kind это тег, по которому TS сужает тип в каждой ветке. Без тега Shape всё равно работает, но компилятор не понимает, к какой альтернативе мы обращаемся, и заставляет проверять каждое поле отдельно.

Узкий тип лучше широкого. Если число должно быть от 1 до 16, описывай его именно так, а не number-ом с комментарием “от 1 до 16”. Комментарий не помешает положить туда 42, узкий тип, помешает.

ADT и discriminated union

Алгебраический тип данных это любая комбинация sum и product. Самый показательный пример, бинарное дерево:

type Tree<A> =
  | { kind: 'empty' }
  | { kind: 'node'; value: A; left: Tree<A>; right: Tree<A> };

Дерево это либо пустое, либо узел с тремя полями. Никакого “полу-узла”, “узла без значения”, “узла с одним полем left и без right”. Каждое значение Tree<A>, ровно одно из двух.

Обход через pattern matching на теге:

const depth = <A>(tree: Tree<A>): number => {
  switch (tree.kind) {
    case 'empty':
      return 0;
    case 'node':
      return 1 + Math.max(depth(tree.left), depth(tree.right));
  }
};

switch поверх тега, рекурсия по структуре. Компилятор проверяет, что обработаны обе ветки, потому что tree.kind это литеральный union. Забыл case 'node', компилятор подскажет.

Pattern matching хочется писать декларативнее, для этого есть библиотека ts-pattern:

import { match } from 'ts-pattern';

const depth = <A>(tree: Tree<A>): number =>
  match(tree)
    .with({ kind: 'empty' }, () => 0)
    .with({ kind: 'node' }, ({ left, right }) =>
      1 + Math.max(depth(left), depth(right)),
    )
    .exhaustive();

exhaustive() падает на этапе компиляции, если ветка пропущена. То же самое, что у switch, но без явного default. В проектах, где ADT много, ts-pattern экономит шум.

Make illegal states unrepresentable

Идея простая: вместо комментария “не больше 16” опиши это в типе, и компилятор сам не даст ошибиться. Проверять руками, дорогая работа, которую любят забывать.

// плохо, тип широкий, правило в комментарии
type Pool = { parallelism: number /* от 1 до 16 */ };
const pool: Pool = { parallelism: 100 }; // компилятор не возразит

// лучше, тип сам обозначает диапазон
type Parallelism = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16;
type Pool = { parallelism: Parallelism };
const pool: Pool = { parallelism: 100 }; // ошибка компиляции

Литеральный union ужал множество с миллиардов значений до шестнадцати. “Не больше 16” теперь, не комментарий, а свойство типа.

Для типов, чьё содержимое нельзя описать литералом, помогают branded types:

type EmailAddress = string & { readonly __brand: 'EmailAddress' };

const parseEmail = (raw: string): EmailAddress | null => {
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw)) return null;
  return raw as EmailAddress;
};

// функции принимают только проверенный email
const send = (to: EmailAddress, body: string): void => {
  // тут уже не надо валидировать
};

Создать EmailAddress напрямую из строки нельзя, нужен smart constructor parseEmail. Дальше по программе валидация не нужна: тип сам говорит, что email уже проверен.

Тот же приём для непустых массивов, положительных целых, координат, паролей, дискриминаторов. Везде, где есть инвариант.

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

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

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