Раздел 46 · Effect: Тихая Академия

keyof и отображённые типы

middle~25 мин

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

Таблица Ринцвальда это объект, и тип у него такой:

const handlers: Record<string, (message: Message) => string> = {
  Text: (message) => (message._tag === 'Text' ? message.body : ''),
  Ack: (message) => (message._tag === 'Ack' ? `принят кадр ${message.of}` : ''),
  Ping: () => 'башня на связи',
};

Message тот же, что в уроке 07: Text | Ack | Ping | Lost. Тип таблицы врёт дважды. Ключом может быть любая строка, поэтому компилятор не заметил, что Lost нет. И каждый обработчик получает общий Message, поэтому внутри каждого Ринцвальд заново сужает тег, хотя таблица и так знает, чей это обработчик.

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

keyof: список полей

У карточки каталога есть поля: автор, полка, номер. Если спросить Библиотекаря не «что в поле автор», а «какие поля есть у карточки», он покажет названия полей. Это и есть keyof.

type Frame = {
  readonly from: string;
  readonly to: string;
  readonly glyphs: readonly Glyph[];
};

type FrameKey = keyof Frame;
// 'from' | 'to' | 'glyphs'

keyof берёт объектный тип и отдаёт объединение его ключей, литералами. Это работает и с типом значения: у таблицы кодов CODE из этапа 1 тип Readonly<Record<Glyph, number>>, и keyof typeof CODE даёт Glyph.

С объединением keyof ведёт себя неожиданно, пока не вспомнишь множества из урока 05:

type MessageKey = keyof Message;
// '_tag'

Значение типа Message это одно из четырёх сообщений, неизвестно какое. Гарантированно у него есть только те поля, что есть у всех четырёх, а общее у них одно: _tag. Чем шире множество значений, тем меньше ключей можно обещать.

Индексный доступ: что лежит в поле

keyof отвечает «какие поля», индексный доступ T[K] отвечает «что в поле». Синтаксис тот же, что у доступа к свойству объекта, только в мире типов:

type Glyphs = Frame['glyphs'];
// readonly Glyph[]

type Ends = Frame['from' | 'to'];
// string

type One = Glyphs[number];
// Glyph

В скобках может стоять объединение ключей, и тогда выйдет объединение типов полей. [number] у массива значит «элемент по любому числовому индексу», то есть тип элемента.

Самое полезное применение для протокола: индексный доступ по объединению.

type Tag = Message['_tag'];
// 'Text' | 'Ack' | 'Ping' | 'Lost'

TypeScript взял поле _tag у каждого члена объединения и сложил результаты. Список тегов больше не нужно писать руками: он выведен из сообщений. Добавь пятое сообщение, и Tag вырастет сам.

Отображённые типы: таблица по списку

Библиотекарь составляет ведомость: берёт список тегов и на каждый заводит строку. Строк столько, сколько тегов, ни одной лишней. В TypeScript такую ведомость зовут отображённым типом:

type Counts = { [T in Tag]: number };
// { Text: number; Ack: number; Ping: number; Lost: number }

Читай вслух: «для каждого T из Tag ключ T со значением number». Квадратные скобки с in похожи на цикл for...in, только перебирают члены объединения, а результат это тип объекта. Переменная T видна справа, поэтому значение может зависеть от ключа: { [T in Tag]: T } даст { Text: 'Text'; Ack: 'Ack'; ... }.

Ты уже пользовался отображёнными типами, не зная этого. Вот Record из lib.es5.d.ts:

type Record<K extends keyof any, T> = {
  [P in K]: T;
};

Counts это ровно Record<Tag, number>, компилятор считает их одним типом.

Модификаторы

Перед ключом можно поставить readonly, после ключа ?. С минусом они снимаются: -readonly делает поле изменяемым, -? делает его обязательным. Если перебор идёт по keyof T, модификаторы исходного типа копируются, и минус их убирает. Так устроены Partial, Required и Readonly из той же библиотеки:

type Partial<T> = {
  [P in keyof T]?: T[P];
};

type Required<T> = {
  [P in keyof T]-?: T[P];
};

Partial<Frame> даёт { readonly from?: string; readonly to?: string; readonly glyphs?: readonly Glyph[] }: readonly пришёл от Frame, ? добавил Partial. А черновик кадра, который можно править по полю, выглядит так:

type Draft<T> = { -readonly [K in keyof T]: T[K] };

type FrameDraft = Draft<Frame>;
// { from: string; to: string; glyphs: readonly Glyph[] }

readonly снялся только с самих полей. Массив внутри остался readonly Glyph[]: модификатор массива это часть типа значения, а не ключа.

Помни про флаг exactOptionalPropertyTypes из урока 04. С ним ? значит «ключа может не быть», а не «в ключе может лежать undefined». Пустой объект {} подходит к типу, где все поля с ?, а { from: undefined } к Partial<Frame> не подходит.

Переименование ключей через as

После перебора можно сказать, как назвать ключ: as и новое имя. Имя часто собирают шаблонной строкой, подробно о них в уроке 12, а пока хватит того, что `count${T}` склеивает слово и тег:

type Counters = { readonly [T in Tag as `count${T}`]: number };
// { readonly countText: number; readonly countAck: number; readonly countPing: number; readonly countLost: number }

Если as вернёт never, ключ пропадёт: так из перебора выкидывают лишнее. Как вычислить never по условию, разберём в следующем уроке.

А теперь главный трюк урока. Перебирать можно не ключи, а члены любого объединения, и ключ вычислить из члена:

type ByTag = { readonly [M in Message as M['_tag']]: M };
// { readonly Text: Text; readonly Ack: Ack; readonly Ping: Ping; readonly Lost: Lost }

M по очереди становится Text, Ack, Ping, Lost. Ключом служит тег этого сообщения, значением само сообщение. Получилась картотека: по тегу достаёшь тип сообщения, ByTag['Ping'] это Ping.

Таблица обработчиков

Из трёх кусков собирается тип таблицы, которую хотел Ринцвальд:

type Handlers<R> = { readonly [T in Tag]: (message: ByTag[T]) => R };

type PingHandler = Handlers<string>['Ping'];
// (message: Ping) => string

Вслух: «для каждого тега T функция из сообщения с этим тегом в R». R это то, что возвращают все обработчики: строка для сводки, число для счётчика.

Теперь компилятор требует все четыре строки таблицы. Забыл Lost:

export const describe: Handlers<string> = {
  Text: (message) => message.body,
  Ack: (message) => `принят кадр ${message.of}`,
  Ping: () => 'башня на связи',
};
describe.ts(10,14): error TS2741: Property 'Lost' is missing in type '{ Text: (message: Text) => string; Ack: (message: Ack) => string; Ping: () => string; }' but required in type 'Handlers<string>'.

И каждый обработчик получил своё сообщение: в Ack параметр message уже Ack, поэтому message.of собирается без проверки тега. Это контекстная типизация из прошлого урока: тип таблицы подсказал стрелке тип параметра.

Осталась функция, которая берёт таблицу и сообщение и зовёт нужный обработчик. Первой напрашивается одна строка:

export const match =
  <R>(handlers: Handlers<R>) =>
  (message: Message): R =>
    handlers[message._tag](message);

Перевод с Ук

match.ts(13,28): error TS2345: Argument of type 'Message' is not assignable to parameter of type 'never'.
  The intersection 'Text & Ack & Ping & Lost' was reduced to 'never' because property '_tag' has conflicting types in some constituents.
    Type 'Text' is not assignable to type 'never'.

Снизу вверх. Последняя строка: Text не подходит к never. Средняя объясняет, откуда взялся never: TypeScript пересёк все четыре сообщения, Text & Ack & Ping & Lost, и пересечение оказалось пустым, потому что _tag не может быть одновременно 'Text' и 'Ack'. Верхняя: аргумент Message не подходит к параметру never.

Откуда пересечение? handlers[message._tag] TypeScript вычисляет один раз для всех тегов сразу, и выходит объединение четырёх функций: может, обработчик текста, может, пинга. Вызвать «одну из четырёх функций, неизвестно какую» безопасно только аргументом, который подойдёт каждой. Это пересечение их параметров, книга, которая стоит на всех четырёх полках разом. Такой книги нет. Почему у функций параметры при объединении пересекаются, подробно в уроке 13.

Ты знаешь больше компилятора: тег сообщения и ключ обработчика это одна и та же строка. TypeScript эту связь не прослеживает. Такое место одно, оно спрятано внутри match, и здесь допустимо приведение с комментарием, почему оно честное. Снаружи match типизирован полностью, и вызывающему коду as не нужен.

Где тут дыра

Вернёмся к таблице Ринцвальда. Пока ты читал, он добавил Pong («пусть будет, вдруг пригодится») и функцию разбора. С флагом noUncheckedIndexedAccess строка handlers[message._tag](message) не собирается: в Record<string, ...> под любым ключом может не оказаться значения. Ринцвальд ставит восклицательный знак:

const handlers: Record<string, (message: Message) => string> = {
  Text: (message) => (message._tag === 'Text' ? message.body : ''),
  Ack: (message) => (message._tag === 'Ack' ? `принят кадр ${message.of}` : ''),
  Ping: () => 'башня на связи',
  Pong: () => 'башня на связи',
};

const describe = (message: Message): string => handlers[message._tag]!(message);
console.log(describe({ _tag: 'Text', body: 'гроза' }));
console.log(describe({ _tag: 'Lost', since: 3 }));
гроза
TypeError: handlers[message._tag] is not a function

На тексте работает, на потере падает: обработчика Lost нет, а ! пообещал компилятору, что есть. Опечатку Pong тип тоже пропустил, потому что Pong это строка. С Handlers<string> обе ошибки видны до запуска: про Lost ты видел выше, а лишний Pong компилятор не пустит в таблицу, у которой нет такого ключа. Восклицательный знак похож на приведение внутри match, но разница в том, кто прав. В match ты знаешь больше компилятора, а здесь ! обещает обработчик, которого нет.

Шифр, этап 5

Протокол башни учится разбирать сообщения таблицей вместо switch. Тег, картотека и таблица обработчиков выводятся из Message, поэтому новое сообщение потребует свой обработчик во всех таблицах сразу. Ещё башне нужны флаги журнала: какие сообщения записывать, по флагу на тег.

Практика

Дальше

Таблица собрана, и Ринцвальд уходит довольный. Пондер провожает его взглядом и возвращается к твоей карточке с Effect<A, E, R>. «У промиса ошибка any, у таблицы обработчиков ошибок нет вовсе. Задача, которая знает свою ошибку. Сделаем свою». Как собрать её и как потом достать из её типа, что она отдаёт и чем падает, разберём в следующем уроке.