Кадр подписан строкой 'T-1'. В уроке 12 ты завёл тип TowerCode, `T-${number}`, и он честно отвечает на один вопрос: похожа ли строка на код башни. На вопрос «кто эту строку написал и проверил ли кто-нибудь, что такая башня есть» тип не отвечает. Функция isTowerCode у тебя есть, но вызывать её никто не обязан.
В этом уроке ты сделаешь номер башни, который нельзя написать от руки, и номер сообщения, за который не выдать чужой объект. По дороге выяснится, почему два разных класса в TypeScript бывают одним типом, и чем это грозит ошибкам с одинаковым _tag.
Форма, а не имя
Вот как выглядела подделка с точки зрения компилятора:
import type { TowerCode } from './towers';
type Signed = { readonly from: TowerCode; readonly body: string };
const sign = (from: TowerCode, body: string): Signed => ({ from, body });
const forged = sign('T-1', 'Ack 41');
Здесь towers.ts это две строки из урока 12: тип TowerCode и предикат isTowerCode. Строка 'T-1' имеет нужную форму, и всё. Компилятор не спрашивает, откуда она взялась.
Представь пропуск в Библиотеку. Привратник, который читает только текст на пропуске, пустит любого, кто переписал этот текст на свою бумажку. Привратник, который смотрит на печать, пустит только того, кому пропуск выдали. TypeScript по умолчанию первый привратник. Он сравнивает типы по форме: какие поля, какого они типа. Это называется структурной типизацией. Второй привратник, тот, что смотрит на печать, это номинальная типизация, как у классов в Java или C#.
Для JavaScript структурный подход правильный: объекты приходят отовсюду, из JSON, из чужих библиотек, из литералов, и требовать от каждого «паспорт класса» было бы мучением. Но для номеров башен нам нужна печать: «это значение прошло проверку». Печать придётся поставить самим.
Бренд: поле, которого нет
Ни у одной строки нет поля __brand. Если потребовать его в типе, обычная строка не подойдёт:
import { isTowerCode, type TowerCode } from './towers';
type TowerId = TowerCode & { readonly __brand: 'TowerId' };
const towerId = (value: string): TowerId | undefined =>
isTowerCode(value) ? (value as TowerId) : undefined;
const sign = (from: TowerId, body: string) => ({ from, body });
const academy = towerId('T-1');
if (academy) sign(academy, 'Ping');
sign('T-1', 'Ack 41');
TowerId это пересечение из урока 05: строка, похожая на код башни, и объект с полем __brand. В рантайме такого поля не появится никогда, это бренд, клеймо на типе. Получить TowerId можно только через as, и единственный as во всём коде стоит в towerId, сразу после проверки. Функция, которая проверяет вход и только потом ставит бренд, называется умным конструктором.
Перевод с Ук
brand-string.ts(13,6): error TS2345: Argument of type '"T-1"' is not assignable to parameter of type 'TowerId'.
Type 'string' is not assignable to type '{ readonly __brand: "TowerId"; }'.
Снизу вверх. Вторая строка: string не подходит вместо объекта с полем __brand. Это одна из двух половин пересечения: форма `T-${number}` у 'T-1' сошлась, а поля бренда у строки нет. Компилятор пишет string, а не 'T-1': сравнивая значение с объектом, он смотрит на строку вообще, ведь полей у литерала 'T-1' не больше, чем у любой строки. Первая строка: значит, аргумент 'T-1' не подходит параметру TowerId в вызове на тринадцатой строке, шестой столбец.
Перевод на язык Рыдкулла: «номер без печати». Получить печать можно у towerId, а она ставит её только после проверки. Подделке теперь нужен as TowerId прямо в коде, и этот as видно любому, кто читает код. От того, кто сознательно врёт компилятору, бренд не защищает. Он защищает от случайности и делает подделку уликой: одна строка, которую можно найти поиском.
Почему не строка в бренде
У строкового бренда есть слабое место. Имя 'TowerId' придумать может кто угодно. Вот библиотека Магистрали, со своим типом башни и тем же клеймом:
export type RelayTower = `T-${number}` & { readonly __brand: 'TowerId' };
export const relayTower = (value: string): RelayTower => value as RelayTower;
Её конструктор ничего не проверяет. А компилятор видит две одинаковые формы:
import { relayTower } from './magistral';
import type { TowerCode } from './towers';
type TowerId = TowerCode & { readonly __brand: 'TowerId' };
const trusted: TowerId = relayTower('T-12a');
Ни звука. Башня T-12a из урока 12, которой нет на карте, прошла как проверенная башня Академии. Два бренда с одинаковой строкой это один бренд.
Нужен ключ, который нельзя повторить. В TypeScript это unique symbol:
import { isTowerCode, type TowerCode } from './towers';
declare const towerIdBrand: unique symbol;
export type TowerId = TowerCode & { readonly [towerIdBrand]: true };
export const towerId = (value: string): TowerId | undefined =>
isTowerCode(value) ? (value as TowerId) : undefined;
const academy = towerId('T-1');
const code: TowerCode | undefined = academy;
console.log(academy?.length, code);
Разберём третью строку по словам.
declareговорит компилятору: «такая константа есть, кода для неё не пиши». В рантаймеtowerIdBrandне существует, символ живёт только в типах.unique symbolзначит, что у этой константы собственный тип, не совпадающий ни с каким другим символом. Объяви в другом файлеdeclare const towerIdBrand: unique symbolс тем же именем, и получишь другой тип.- Константа не экспортируется. Ключ
[towerIdBrand]вне модуля не написать вовсе, даже если очень хочется.
Тот же пример с Магистралью теперь получает «Ук»:
brand-collide-sym.ts(7,7): error TS2322: Type 'RelayTower' is not assignable to type 'TowerId'.
Property '[towerIdBrand]' is missing in type 'String & { readonly __brand: "TowerId"; }' but required in type '{ readonly [towerIdBrand]: true; }'.
Последние строки примера показывают, что бренд ничего не отнимает. TowerId остаётся строкой с длиной и методами и подходит везде, где ждут TowerCode: в терминах урока 05 полка проверенных башен вложена в полку кодов башен. Обратно не подходит.
Классы тоже сравниваются по форме
Номер сообщения у нас будет классом. Кто пришёл из Java, ждёт, что класс сам по себе номинален. Проверим:
class MessageId {
readonly _tag = 'MessageId' as const;
constructor(readonly value: number) {}
}
const take = (id: MessageId): number => id.value;
take(new MessageId(41));
take({ _tag: 'MessageId', value: 41 });
Собирается. Для TypeScript класс это форма экземпляра: поле _tag и поле value. Литерал с такими же полями подходит. Тот, кто подделал номер башни, подделает и номер сообщения.
Лечится одним объявлением:
class MessageId {
readonly _tag = 'MessageId' as const;
declare protected readonly brand: never;
constructor(readonly value: number) {}
}
const take = (id: MessageId): number => id.value;
take(new MessageId(41));
take({ _tag: 'MessageId', value: 41 });
class-brand.ts(10,6): error TS2345: Argument of type '{ _tag: "MessageId"; value: number; }' is not assignable to parameter of type 'MessageId'.
Property 'brand' is missing in type '{ _tag: "MessageId"; value: number; }' but required in type 'MessageId'.
По словам:
protectedэто главное. В TypeScript есть правило: если у типа естьprivateилиprotectedчлен, то подходит только тот тип, у которого этот член объявлен в том же классе (или достался от него наследнику). Литерал такого члена не имеет и не может иметь. Чужой класс может объявить поле с тем же именем, но объявлено оно будет в другом месте.privateсработал бы так же.declareзначит, что поле есть только в типе. Без него компилятор сtargetES2022 и новее создал бы у каждого экземпляра настоящее полеbrandсо значениемundefined, и оно вылезло бы вObject.keysи в сравненияхtoEqual. Сdeclareуnew MessageId(1)ключей ровно два,_tagиvalue.readonlyиnever: записать в поле нечего, прочитать оттуда тоже нечего. Это клеймо, а не данные.
Коллизия _tag
Теперь посмотри на ошибки. В Академии и в Магистрали есть свой таймаут, и разработчики, не сговариваясь, назвали тег одинаково:
class TowerTimeout {
readonly _tag = 'TowerTimeout' as const;
}
class RelayTimeout {
readonly _tag = 'TowerTimeout' as const;
}
const retryTower = (error: TowerTimeout): string => `повторить: ${error._tag}`;
retryTower(new RelayTimeout());
Собирается. Таймаут Магистрали структурно совпал с таймаутом башни, и retryTower начнёт перезапускать башню Академии каждый раз, когда тормозит чужой ретранслятор. В Effect ошибки устроены так же, классами с полем _tag, поэтому такая коллизия ждёт тебя там в каждом проекте, где ошибки пишут несколько человек.
То же лекарство:
class TowerTimeout {
readonly _tag = 'TowerTimeout' as const;
declare protected readonly brand: never;
}
class RelayTimeout {
readonly _tag = 'TowerTimeout' as const;
declare protected readonly brand: never;
}
const retryTower = (error: TowerTimeout): string => `повторить: ${error._tag}`;
retryTower(new TowerTimeout());
retryTower(new RelayTimeout());
tag-nominal.ts(13,12): error TS2345: Argument of type 'RelayTimeout' is not assignable to parameter of type 'TowerTimeout'.
Property 'brand' is protected but type 'RelayTimeout' is not a class derived from 'TowerTimeout'.
Вторая строка ошибки говорит правило дословно: поле brand защищённое, а RelayTimeout не наследник TowerTimeout. Имена и типы полей совпадают, но объявлены поля в разных классах.
Есть и вторая половина этой истории, и она неприятнее. Защита работает только в типах. Когда в уроке 39 ты будешь ловить ошибки по тегу через Effect.catchTag, сравнение пойдёт в рантайме по строке _tag, и обработчик для 'TowerTimeout' поймает оба таймаута, сколько бы protected ты ни объявил. Поэтому в Effect теги ошибок делают уникальными на всю программу, а в библиотеках добавляют имя пакета, как 'bashnya/TowerTimeout'. Подробно об этом в уроке 39.
Где тут дыра
import type { TowerCode } from './towers';
declare const towerIdBrand: unique symbol;
type TowerId = TowerCode & { readonly [towerIdBrand]: true };
const towerId = (value: string): TowerId => value as TowerId;
const sign = (from: TowerId, body: string) => ({ from, body });
sign(towerId('T-1'), 'Ping');
sign(towerId('T-12a'), 'Ack 41');
На своём примере Ринцвальд прав: голую строку sign больше не принимает, а towerId('T-1') выдаёт честную башню. Соседняя строка выдаёт за проверенную башню T-12a, которой нет на карте, и компилятор молчит: as разрешил превратить любую строку в TowerId, а проверки внутри нет. Сигнатура тоже врёт: towerId обещает вернуть башню всегда, хотя для мусора честный ответ undefined.
Бренд это обещание, что проверка была. Он не проверяет сам. Конструктор без проверки это зелёный отчёт Магистрали: печать стоит, а за ней ничего.
Шифр, этап 9
Подпись T-1 под чужим кадром больше не должна пройти. Протокол получает номер башни с брендом на unique symbol, который выдаёт только проверка, и номер сообщения, за который не выдать ни литерал, ни чужой класс с тем же тегом. Так на проводе останутся только кадры, чьи номера кто-то честно проверил.
Практика
Глубже в архиве
Бренды на границах приложения, рядом со схемами валидации и Result, разбирает урок «Надёжность и типы на границах» из курса TypeScript: там видно, как умный конструктор встаёт на вход API и в базу.
Дальше
Вечером ты показываешь протокол Библиотекарю. Тесты зелёные: towerId('T-9') отдаёт 'T-9', мусор даёт undefined. Библиотекарь долго листает файл с тестами, находит место, где должна стоять проверка «строку за башню не выдать», и стучит пальцем по пустой строке.
Тест в рантайме не видит бренда: в рантайме его нет. Если завтра кто-то уберёт клеймо, тесты останутся зелёными. Кто проверит проверяющего, разбираемся в следующем уроке.