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

Бланк договора: Struct, Union, Literals

middle~20 мин

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

В прошлом уроке записка была из четырёх полей, все числа и строки. Настоящий кадр Магистрали устроен сложнее: в нём есть слова из короткого списка, пары, словари, сообщения разных видов и поле, которого может не быть. Для каждого случая в Schema есть своя деталь, и из них, как из кубиков, собирается бланк.

Кадр Магистрали

{
  "id": 7,
  "route": ["T-9", "T-1"],
  "priority": "urgent",
  "headers": { "смена": "ночь" },
  "message": { "kind": "text", "body": "гроза" },
  "relay": "T-5"
}

Шесть полей и седьмое, relay, только у кадров, которые шли через ретранслятор. Разберём их по одному.

Слово из списка

Срочность бывает low, normal или urgent. Это не любая строка, а одно из трёх слов, и схема так и пишется:

export const Priority = Schema.Literals(['low', 'normal', 'urgent']);
export type Priority = typeof Priority.Type; // 'low' | 'normal' | 'urgent'

Schema.Literals берёт список значений, а тип выводит объединением литералов, как в уроке 05. Одно значение пишут Schema.Literal('text'). Кадр со срочностью high разбор не пропустит:

Expected "low" | "normal" | "urgent"
  at ["priority"]

Один из четырёх

Сообщение кадра бывает четырёх видов, и поле kind говорит, какого:

export const Text = Schema.Struct({ kind: Schema.Literal('text'), body: Schema.String });
export const Ack = Schema.Struct({ kind: Schema.Literal('ack'), of: Schema.Int });
export const Ping = Schema.Struct({ kind: Schema.Literal('ping') });
export const Lost = Schema.Struct({ kind: Schema.Literal('lost'), since: Schema.Int });

export const Message = Schema.Union([Text, Ack, Ping, Lost]);
export type Message = typeof Message.Type;

Schema.Union берёт массив схем, и значение подходит, если подходит хоть к одной. Выведенный тип это размеченное объединение из урока 07, и switch по kind сужает его так же, как сужал сообщения шифра:

const describe = (message: Message): string => {
  switch (message.kind) {
    case 'text':
      return `текст: ${message.body}`;
    case 'ack':
      return `принято ${message.of}`;
    case 'ping':
      return 'пинг';
    case 'lost':
      return `потеряно с ${message.since}`;
  }
};

Номера в ack и lost это Schema.Int: целое число. Дробь 1.5 номером кадра не бывает, а NaN JSON вообще не перенесёт. Сообщение незнакомого вида разбор отвергает и перечисляет, что ждал:

Expected { readonly "kind": "text", ... } | { readonly "kind": "ack", ... } | { readonly "kind": "ping", ... } | { readonly "kind": "lost", ... }
  at ["message"]

Пара и словарь

Маршрут это ровно две башни. Массив любой длины тут не годится, нужен кортеж:

const Route = Schema.Tuple([Schema.String, Schema.String]); // readonly [string, string]

Служебные строки Магистрали это словарь: имена заранее неизвестны, но и имя, и значение строки.

export const Headers = Schema.Record(Schema.String, Schema.String); // { readonly [x: string]: string }

Маршрут из одной башни и служебная строка с числом:

Missing key
  at ["route"][1]

Expected string
  at ["headers"]["смена"]

Путь в ошибке ведёт прямо к месту: второй элемент маршрута, служебная строка смена.

Поле, которого может не быть

relay есть только у кадров через ретранслятор. В схеме для этого две детали, и разница между ними тонкая:

  • Schema.optionalKey(Schema.String): ключа может не быть, но если он есть, в нём строка. Тип поля readonly relay?: string.
  • Schema.optional(Schema.String): ключа может не быть, а если он есть, в нём строка или undefined. Тип readonly relay?: string | undefined.

Под флагом exactOptionalPropertyTypes, который стоит у шага Т, это разные типы, и это не придирка. «Ключа нет» и «ключ есть, а в нём undefined» для JavaScript разные объекты: 'relay' in frame отвечает на них по-разному. Для кадра Магистрали правильный ответ optionalKey: ретранслятор либо был и у него есть код, либо его не было.

Собираем бланк:

export const RawFrame = Schema.Struct({
  id: Schema.Number,
  route: Schema.Tuple([Schema.String, Schema.String]),
  priority: Priority,
  headers: Headers,
  message: Message,
  relay: Schema.optionalKey(Schema.String),
});

export type RawFrame = typeof RawFrame.Type;

Перевод с Ук

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

import { Schema } from 'effect';

const RawFrame = Schema.Struct({
  id: Schema.Number,
  relay: Schema.optionalKey(Schema.String),
});
type RawFrame = typeof RawFrame.Type;

declare const relayOf: (line: string) => string | undefined;

export const frame: RawFrame = { id: 7, relay: relayOf('T-9->T-1') };
frame.ts(11,14): error TS2375: Type '{ id: number; relay: string | undefined; }' is not assignable to type '{ readonly id: number; readonly relay?: string; }' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
  Types of property 'relay' are incompatible.
    Type 'string | undefined' is not assignable to type 'string'.
      Type 'undefined' is not assignable to type 'string'.

Снизу вверх. undefined нельзя положить туда, где ждут string. Строкой выше: это поле relay. Ещё выше главное: у цели relay?: string, ключа может не быть, но undefined в нём не бывает, и всё это под флагом exactOptionalPropertyTypes. Совет компилятора «добавь undefined в тип цели» здесь как раз неправильный: тогда бланк станет optional, и в кадре появится ключ без значения. Правильно не класть ключ, когда ретранслятора нет:

const relay = relayOf('T-9->T-1');
export const frame: RawFrame = relay === undefined ? { id: 7 } : { id: 7, relay };

Где тут дыра

const Loose = Schema.Struct({
  id: Schema.Number,
  relay: Schema.optional(Schema.String),
});

const relayOf = (line: string): string | undefined => line.split('->')[2];
const hops = (frame: object): number => ('relay' in frame ? 2 : 1);

const frame = Schema.decodeUnknownSync(Loose)({ id: 7, relay: relayOf('T-9->T-1') });
console.log(frame, hops(frame));
{ id: 7, relay: undefined } 2

Кадр шёл напрямую, а башня насчитала два перегона: ключ relay есть, хоть в нём и пусто. optional пропустил undefined и сохранил ключ. Со схемой optionalKey разбор ответил бы Expected string at ["relay"], и кадр без ретранслятора пришлось бы собрать без ключа. Шире не значит надёжнее: бланк, который принимает больше, чем бывает в мире, пропускает ошибки дальше.

Магистраль, этап 2

Академия пишет полный бланк кадра Магистрали: слово срочности, четыре вида сообщения, маршрут, служебные строки и ретранслятор. Коды башен пока простые строки; печать на них поставим в следующем уроке.

Практика

Глубже в архиве

Размеченные объединения как способ описать предметную область разобраны в уроке «Алгебраические типы и моделирование домена».

Дальше

Ринцвальд смотрит в бланк через плечо и хмыкает.

Печати на значениях ждут в следующем уроке.