В каждом клиенте API прячется конечный автомат. Сначала handshake. Потом аутентификация. Затем отправка данных. Закрытие в конце. Нарушьте этот порядок — получите runtime-ошибки, сбитые с толку серверы или, что хуже, тихое повреждение данных.

Большинство команд кодируют эти правила через runtime-проверки. if (!connected) throw new Error(...). Это работает, пока кто-то не забудет проверку, или refactor не внесёт новый путь в коде, который её пропускает. Когда вы замечаете, баг уже в production.

Система типов TypeScript может устранить целый класс таких багов. Не через хитрые lint-правила, а делая недопустимые состояния буквально непредставимыми.

В вашем клиенте API прячется скрытый конечный автомат

Session types — это техника системы типов для кодирования допустимой последовательности операций в протоколе. Вместо отслеживания состояния через строковое поле на runtime, вы отслеживаете его в параметре типа. Channel<'idle'> может вызывать только connect(). Channel<'connected'> может вызывать только send() и close(). Компилятор отклоняет всё остальное.

Это не академизм. Если вы когда-либо использовали транзакцию базы данных, WebSocket-соединение или OAuth flow, вы вручную обеспечивали соблюдение session-протокола. Session types просто переносят это enforcement из runtime в compile time.

Phantom types делают недопустимые переходы непредставимыми

Вот типичный конечный автомат на TypeScript:

class Channel {
  private state: 'idle' | 'connected' | 'closed' = 'idle';

  connect() {
    if (this.state !== 'idle') throw new Error('Already connected');
    this.state = 'connected';
  }

  send(msg: string) {
    if (this.state !== 'connected') throw new Error('Not connected');
    // ...
  }

  close() {
    this.state = 'closed';
  }
}

Это нормально, пока не перестаёт быть нормальным. Метод send() бросает исключение на runtime, если вызвать его в неправильное время. Тестирование может поймать. А может и нет. Refactor может внести новый вызов send() после close(), который никто не заметит. У компилятора нет мнения.

TypeScript использует структурную типизацию. Два класса с одинаковой формой взаимозаменяемы, даже если их параметры типа различаются. Чтобы сделать Channel<'idle'> и Channel<'connected'> несовместимыми, параметр типа должен присутствовать в самой структуре.

Стандартный паттерн — private phantom field:

class Channel<State extends string> {
  private __state!: State;
  private constructor() {}

  static create(): Channel<'idle'> {
    return new Channel();
  }

  connect(this: Channel<'idle'>): Channel<'connected'> {
    return new Channel();
  }

  send(this: Channel<'connected'>, msg: string): Channel<'connected'> {
    console.log(msg);
    return new Channel();
  }

  close(this: Channel<'connected'>): Channel<'closed'> {
    return new Channel();
  }
}

Поле __state никогда не получает значения. Оно существует только для привязки параметра типа к структуре класса. Поскольку оно private, внешний код не может сфабриковать Channel<'connected'>. Поскольку оно имеет тип State, Channel<'idle'> и Channel<'connected'> — структурно разные типы.

Параметр this на каждом методе ограничивает, какие состояния могут его вызывать. TypeScript проверяет совместимость this в месте вызова, а не только внутри тела метода.

Теперь недопустимые вызовы — ошибки компиляции:

const ch = Channel.create();
ch.send('hello'); // Error: 'Channel<"idle">' is not assignable to 'Channel<"connected">'

Это та часть, где люди спотыкаются. Параметр this выглядит как runtime-аннотация. Это не так. TypeScript использует его чисто для type-checking получателя. Если тип получателя не совпадает, компилятор полностью отклоняет вызов.

Построение type-safe протокола загрузки файлов

Игрушечный Channel демонстрирует паттерн. Вот что-то ближе к реальному протоколу: клиент загрузки файлов с аутентификацией и проверкой контрольной суммы.

type UploadState = 'idle' | 'ready' | 'uploading' | 'verifying' | 'done';

class Uploader<State extends UploadState> {
  private __state!: State;
  private file: string;

  private constructor(file: string) {
    this.file = file;
  }

  static forFile(file: string): Uploader<'idle'> {
    return new Uploader(file);
  }

  authenticate(
    this: Uploader<'idle'>,
    token: string
  ): Uploader<'ready'> {
    // verify token...
    return new Uploader(this.file);
  }

  uploadChunk(
    this: Uploader<'ready' | 'uploading'>,
    data: Uint8Array
  ): Uploader<'uploading'> {
    // stream bytes...
    return new Uploader(this.file);
  }

  finalize(
    this: Uploader<'uploading'>,
    checksum: string
  ): Uploader<'verifying'> {
    // start verification...
    return new Uploader(this.file);
  }

  verify(
    this: Uploader<'verifying'>,
    serverChecksum: string
  ): Uploader<'done'> {
    // compare checksums...
    return new Uploader(this.file);
  }
}

Обратите внимание, uploadChunk принимает Uploader<'ready' | 'uploading'> как свой тип this. Union types позволяют выражать методы, допустимые из нескольких состояний. Метод возвращает Uploader<'uploading'>, так что после первого вызова вы заблокированы в состоянии загрузки, пока не вызовете finalize.

const chunk1 = new Uint8Array([0x01, 0x02]);
const chunk2 = new Uint8Array([0x03, 0x04]);

const upload = Uploader.forFile('report.pdf')
  .authenticate('token-123')
  .uploadChunk(chunk1)
  .uploadChunk(chunk2)
  .finalize('abc123')
  .verify('abc123');

// upload is now Uploader<'done'>

Попробуйте вызвать authenticate() после uploadChunk(), или uploadChunk() после verify() — TypeScript немедленно выдаст ошибку.

Где паттерн становится неуклюжим

Паттерн не бесплатен. Самая большая проблема в том, что каждый переход состояния конструирует новый объект. В наших примерах мы создаём новый Uploader, даже когда базовое состояние не изменилось, например uploadChunk возвращает Uploader<'uploading'> из Uploader<'uploading'>. Для сложных объектов с множеством состояний это расточительно.

Можно оптимизировать, пропуская состояние вместо реконструкции, но типы станут шумнее. Вторая проблема: сообщения об ошибках TypeScript для несоответствий параметра this загадочны. «The ‘this’ context of type ‘X’ is not assignable to method’s ‘this’ of type ‘Y’» точно, но не сразу очевидно для того, кто читает код впервые. Хорошие имена и комментарии помогают, но developer experience не идеален.

Третье, этот паттерн плохо компонуется с async-кодом. await посреди цепочки ломает fluent interface, и нужно сохранять промежуточный типизированный результат в переменной. Это не dealbreaker, но это означает, что паттерн работает лучше всего для синхронных или тщательно структурированных async-протоколов.

Tagged unions — прагматичный компромисс

Если полный паттерн phantom types кажется тяжеловесным, tagged unions с исчерпывающими switch-выражениями — прагматичная альтернатива. Вы всё ещё получаете compile-time безопасность, но на уровне значений, а не типов:

type UploadState =
  | { tag: 'idle'; file: string }
  | { tag: 'ready'; file: string; token: string }
  | { tag: 'uploading'; file: string; sent: number }
  | { tag: 'done'; file: string };

function authenticate(
  state: Extract<UploadState, { tag: 'idle' }>,
  token: string
): UploadState {
  return { tag: 'ready', file: state.file, token };
}

Это более идиоматичный TypeScript, и большинству команд его легче поддерживать. Компромисс в том, что вы не можете предотвратить передачу неправильного варианта состояния в функцию на уровне типов без тех же трюков с параметром this. Компилятор ловит это внутри функции, но место вызова не ограничено.

Используйте phantom types, когда нарушения причиняют реальный вред

Используйте phantom types, когда протокол достаточно сложен, чтобы его нарушение причиняло реальный вред, и когда API surface достаточно мал, чтобы вы контролировали каждый переход. Драйверы баз данных, реализации сетевых протоколов и stateful SDK — хорошие кандидаты.

Не используйте их для простых CRUD API или там, где дополнительная сложность типов стоит дороже, чем случайная runtime-проверка.

В следующий раз, когда вы пишете if (state !== 'connected') throw new Error(...), спросите, принадлежит ли эта проверка вашему коду или вашим типам. Параметры this и phantom fields в TypeScript дают вам способ поднять protocol enforcement наверх. Баги, которые вы поймаете, не будут драматичными. Это будут тихие ошибки, которые проскальзывают через code review и проявляются только когда клиент попадает на крайний случай, который вы не протестировали.

Это именно те баги, которые стоит устранять.