
Долгий путь к канону
У каждого разработчика есть шаблоны, используемые им в разных проектах. Код, который переписывается и дорабатывается до тех пор, пока не станет привычным. Для меня таким шаблоном был набор абстракций данных: способы представления об идентичности, типах, отношениях и изменениях во времени.
Создав эти абстракции в достаточном количестве проектов, я решил найти им подходящее место. Так появилась Relational Fabric (Фабрика взаимосвязей) — организация, выявляющая компонуемые элементы, открывающие доступ к сложным системам данных. Основная идея: истинная сила данных заключается в их взаимосвязях, взаимозависимости и способности объединяться в нечто большее.
Видение кристаллизовалось вокруг метафоры: ткачество (переплетение нитей). Так же, как ткань возникает из переплетения нитей, значимые системы данных возникают из взаимодействия отношений. В планах — создание семейства библиотек, названных в честь частей ткацкого станка:
- Filament (Волокно): тонкие нити, формирующие основу, — для создания мета-примитивов построения предметно-ориентированных абстракций;
- Weft (Уток): горизонтальные нити — для навигации и построения запросов;
- Warp (Основа): вертикальные нити — для управления данными в состоянии покоя;
- Shuttle (Челнок): устройство, которое проносит нить через станок, — для координации потоков между системами.
Но принявшись за создание Filament (фундаментального слоя), я осознал, что не хватает чего-то более базового.
Недостающий примитив: логический движок
Многому из того, что должна делать Relational Fabric, требуется логическое рассуждение в режиме выполнения: формулирование утверждений о данных, их компоновка и проверка. Задача Filament — предоставлять примитивы, которые сохраняют смысл, откладывают решения о реализации и элегантно эволюционируют.
Но для этого нужен сам фундамент для логических рассуждений.
Это привело меня к началу работы над движком Howard, названным в честь Уильяма Элвина Говарда — математика, формализовавшего Соответствие Карри — Ховарда в своей работе 1969 года.
Соответствие Карри — Говарда — глубокое понимание взаимоотношений программ и типов, где программы являются доказательствами, а типы — утверждениями. Написание программы, которая удовлетворяет типу, структурно идентично построению математического доказательства для логического утверждения. Тип, который компилируется, — это теорема, которая верна.
Поскольку имя «Карри» уже увековечено в программировании (каррирование), назвать библиотеку в честь Говарда (Howard) показалось правильным. Howard станет вычислимым движком истины: логическим хребтом для наделения данных смыслом.
Howard будет касаться не только типов. Он будет касаться утверждений (claims): первоклассных объектов, которые формализуют утверждения о данных:
// Howard позволяет определять утверждения (claims), которые можно комбинировать
const HasCart = claims({ guards: { hasCart } })
const AUserWithCart = aUser.and(HasCart)
// И доказывать их, получая объяснения, а не просто булевы значения
const proof = prove(AUserWithCart, myUser)
console.log(proof.result) // true или false
console.log(proof.explanation.human()) // почему
Howard будет превращать простые проверки булевых значений в верифицируемые, компонуемые, кэшируемые доказательства. Доказывая утверждения, вы получаете неизменяемую запись оценки: не только результат, но и само рассуждение. Это — практическое воплощение соответствия Карри — Говард для логики в среде выполнения.
Но, приступив к разработке основ Howard, я столкнулся с проблемой.
Проблема многократного использования утверждений
Рассмотрим создание утверждения, которое проверяет, имеет ли объект идентификатор и адрес электронной почты: isUser
const isUser = hasIdentity.and(hasEmail)
Достаточно просто. Но затем вы понимаете, что ваша система имеет несколько представлений данных:
{ id: 'user-123', email: 'alice@example.com' } // внутреннее представление
{ '@id': 'https://...', 'schema:email': 'alice@...' } // JSON-LD
{ _id: '507f1f77bcf86c', email: 'alice@example.com' } // MongoDB
Что дальше? Утверждение hasIdentity должно проверять id в одном формате, @id в другом, _id — в третьем. У вас есть два варианта:
- сделать утверждения эквивалентными (написать утверждения с учетом формата, которые обрабатывают все вариации);
- сделать данные эквивалентными (нормализовать до канонических концепций).
Первый вариант приводит к утверждениям, изобилующим условными конструкциями, или к отдельному утверждению для каждого формата. Ни один из них не подходит. Второй вариант означает установление канонических представлений, на которые утверждения могут ориентироваться единообразно.
Я выбрал второй вариант. Но «сделать данные эквивалентными» может означать две разные вещи:
- Каноническая форма (нормализованная нотация): преобразовать все данные в единое представление. Каждый объект получает поле
id, и точка. Это требует преобразования на границах системы и приводит к потере информации об исходных данных.
- Канонический API (нормализованный доступ): сохранять данные в исходном виде, но обеспечивать единый доступ к семантически эквивалентным концепциям. Данные остаются как
@idили_id, ноidOf(obj)работает со всеми ними.
В некоторых языках применение функций и ассоциативный доступ эквивалентны: например, в Clojure (:id obj) стирается грань между «получить свойство id» и «применить функцию id». Эта эквивалентность намекает на нечто более глубокое: если доступ может быть функцией, то нормализация доступа так же эффективна, как и нормализация данных.
Canon идет по второму пути. Такой способ предоставляет канонические API, а не канонические формы. Данные остаются нативными для своего источника; унифицирован только доступ.
Проблема «пустой комнаты»
Параллельно с этим была еще одна давняя проблема. Каждый раз, начиная работать над новым проектом на TypeScript, будь то для Relational Fabric или что-то еще, я делал одно и то же:
- настраивал одну и ту же конфигурацию TypeScript;
- устанавливал один и тот же набор правил ESLint;
- добавлял одни и те же утилитарные типы;
- определял одни и те же базовые интерфейсы;
- прописывал одни и те же скрипты.
И раз за разом решал одну и ту же проблему «пустой комнаты». Более того, постоянно определял одни и те же семантические концепции: идентификатор, классификацию типов, версионирование, метки времени. Иногда с немного различающимися именами полей в зависимости от проекта или источника данных.
Проблема повторного использования утверждений и проблема «пустой комнаты» указывали на одно решение: каноническую стартовую точку, которая устанавливала бы общие семантические концепции для различных структур данных.
Так родилась библиотека Canon.
Canon: каноническая отправная точка
В описании Canon четко сказано: «Фундаментальная библиотека для полезной экосистемы типов. Канонический источник истины, который решает типичные проблемы проектирования и обеспечивает бесшовную композицию для любого проекта».
Canon предоставляет:
- тщательно подобранные конфигурации: настройки TypeScript и ESLint, воплощающие лучшие практики;
- Canon Kit: тщательный подбор сторонних библиотек (object-hash, immutable.js, defu, consola, fs-extra, ts-morph и других) с продуманным API-интерфейсом, а также прозрачным доступом к исходным библиотекам, когда это необходимо;
- утилиты для тестирования типов: основу для утверждений типов, которые не являются «одноразовым кодом»;
- ленивую типизацию (Lazy Typing): типы с поздней привязкой через расширение интерфейсов и канонические API.
Kit означает, что вы получаете не просто конфигурации, а набор проверенных зависимостей с согласованным версионированием и тщательно продуманным API. Сам Canon использует Kit, когда ему нужны эти возможности, поэтому вы получаете то, что реально используется, а не то, что, как вам кажется, может быть полезным. Прозрачные пути доступа (например, @relational-fabric/canon/_/immutable) гарантируют, что вы всегда можете получить полную функциональность библиотеки, если возможностей базового API вам не хватит.
Эта статья посвящена ленивой типизации и тестированию типов. Именно они концептуально значимы.
Инсайт: расширение интерфейсов и ленивые типы
Расширение модулей (module augmentation) в TypeScript позволяет расширять интерфейсы за пределы модулей:
// В коде библиотеки
export interface Canons {}
// В пользовательском коде
declare module '@relational-fabric/canon' {
interface Canons {
MyFormat: MyFormatType
}
}
Это обеспечивает ленивую типизацию (или позднюю привязку типов). Библиотеке не нужно знать о конкретных формах ваших данных во время компиляции. Вы регистрируете их позже, и система типов адаптируется.
Я мог бы определить семантические концепции («существует понятие идентификатора») и позволить отдельным проектам привязывать эти концепции к их конкретным структурам данных. Одна и та же функция idOf() могла бы работать с id, @id, или _id в зависимости от того, какие каноны были зарегистрированы.
Canon станет канонической отправной точкой для Relational Fabric. А «канонические типы» (типы, привязанные через расширение интерфейсов) — это механизм для работы с семантическими концепциями в разных структурах данных.
Тестирование типов: утверждения, которые не являются «одноразовыми»
Если вы когда-либо писали сложные типы, то наверняка делали так:
type TestFoo = Foo<Bar>
Затем наводили курсор на TestFoo в редакторе, чтобы посмотреть вывод. Это техника отладки. Но такие тестовые идентификаторы загромождают код, вызывая недовольство линтера. Поэтому вы их закомментируете. Или удалите.
И что происходило, когда изменение одного типа нарушало ожидания другого? Вы проваливали тест. Это ожидание было «одноразовым».
Утилиты для тестирования типов в Canon решают эту проблему. Они предоставляют способ писать утверждения типов, которые остаются в кодовой базе, не вызывают недовольство линтера и «ловят» регрессии, когда типы начинают расходиться.
Эти утилиты просты:
type Expect<A, B> = A extends B ? true : false
function invariant<_ extends true>(): void {}
Expect<A, B> возвращает true, если тип A расширяет (удовлетворяет) тип B.
invariant требует, чтобы его параметр типа был true, иначе компиляция не пройдет.
Вместе они позволяют вам писать утверждения о типах:
import type { Expect, IsFalse } from '@relational-fabric/canon'
import { invariant } from '@relational-fabric/canon'
interface Entity {
id: string
createdAt: Date
}
// Компилируется, только если ожидания выполняются
void invariant<Expect<Entity['id'], string>>()
void invariant<Expect<Entity['createdAt'], Date>>()
// Отрицательные утверждения
void invariant<IsFalse<Expect<Entity['createdAt'], string>>>()
Если кто-то изменит createdAt на string, invariant не скомпилируется. Отдельный прогон тестов не требуется: проверки типов — это и есть типы, а неудавшееся утверждение — это просто «сломанный» тип. Мы видим ожидание. Компилятор его обеспечивает. Документация не может лгать.
Нулевая стоимость
Функция invariant компилируется в ничто (не оставляет следа в итоговом коде):
function invariant<_ extends true>(): void {}
В среде выполнения она ничего не делает. Любой сборщик удаляет этот вызов. Вы добавляете проверку на этапе компиляции с нулевыми накладными расходами в среде выполнения.
Ленивая типизация на практике
Тестирование типов полезно само по себе. Но в сочетании с ленивой типизацией появляется нечто более мощное.
Проблема: вы интегрируете данные из нескольких источников, каждый из которых имеет свои собственные соглашения.
// Внутренний формат
{ id: 'user-123', type: 'User' }
// JSON-LD из внешнего API
{ '@id': 'https://example.com/users/123', '@type': 'Person' }
// Документы MongoDB
{ _id: '507f1f77bcf86cd799439011', _type: 'user' }
Семантическая концепция («этот объект имеет идентичность») остается той же. Различается только форма.
Ленивая типизация позволяет один раз определить семантическую концепцию, а затем зарегистрировать формы, которые ее реализуют:
import { declareCanon, pojoWithOfType } from '@relational-fabric/canon'
// Регистрация внутреннего формата
declareCanon('Internal', {
axioms: {
Id: { $basis: pojoWithOfType('id', 'string'), key: 'id' },
Type: { $basis: pojoWithOfType('type', 'string'), key: 'type' },
},
})
// Регистрация JSON-LD
declareCanon('JsonLd', {
axioms: {
Id: { $basis: pojoWithOfType('@id', 'string'), key: '@id' },
Type: { $basis: pojoWithOfType('@type', 'string'), key: '@type' },
},
})
Теперь напишим универсальные функции:
import { idOf, typeOf } from '@relational-fabric/canon'
function logEntity(entity) {
console.log(`[${typeOf(entity)}] ${idOf(entity)}`)
}
// Работает с любой зарегистрированной формой
logEntity({ id: 'user-1', type: 'User' })
logEntity({ '@id': 'https://...', '@type': 'Person' })
Одна функция. Несколько форм. Без условий.
Связь
Проверка типов и ленивая типизация усиливают друг друга.
Ленивая типизация устанавливает систему отношений между типами: аксиомы сопоставляются с формами, формы удовлетворяют ограничениям. Проверка типов подтверждает эти отношения.
import type { Expect, Satisfies } from '@relational-fabric/canon'
import { invariant } from '@relational-fabric/canon'
// Проверяем, удовлетворяют ли наши формы аксиоме Id
void invariant<Expect<Satisfies<'Id', 'Internal'>, { id: string }>>()
void invariant<Expect<Satisfies<'Id', 'JsonLd'>, { '@id': string }>>()
Тесты типов точно документируют то, что гарантирует система ленивой типизации. Если кто-то неправильно настроит канон, инварианты обнаружит это на этапе компиляции.
Названия говорят сами за себя
Названия в Relational Fabric выбраны не случайно. Они образуют целостную метафору:
- Canon: Авторитетная коллекция, каноническая отправная точка. Также — стандартная форма, с которой сравниваются вариации. В музыке — несколько голосов, поющих одну и ту же мелодию со смещением во времени. Разные формы, одинаковый семантический смысл.
- Howard: Уильям Элвин Говард, формализовавший соответствие между логикой и типами. Логический движок, который сделает утверждения первоклассными объектами.
- Filament: тонкие нити, образующие основу ткани. Мета-примитивы, на которых будет строиться всё остальное.
- Weft и Warp: уточные и основные нити в ткачестве. Запросы и хранилище.
- Shuttle: устройство (челнок), которое проносит нить через ткацкий станок. Координация и поток.
Вы будете ткать ткань из взаимосвязей данных. Canon предоставляет начальную нить.
@RelationalFabric (организация)
│
├── canon ← эта статья
│ ├── Ленивые типы (расширение интерфейсов, канонические API)
│ ├── Тестирование типов (утверждения на этапе компиляции)
│ └── Canon Kit (отобранные библиотеки, конфигурации, скрипты)
│
├── howard (в разработке)
│ └── Разработка на основе утверждений (Карри-Говард в среде выполнения)
│
└── relational-fabric (запланировано, основной вход)
├── Filament (мета-уровневые примитивы)
├── Weft (запросы и навигация)
├── Warp (данные в покое)
└── Shuttle (координация и поток)

Начало работы
npm install @relational-fabric/canon
npx canon init # если нужен полный "комплект"
Полная документация и примеры доступны по ссылке relationalfabric.github.io/canon. Для новых проектов руководство по настройке проектов подробно описывает использование команды npx canon init для создания шаблона проекта, в котором уже настроены конфигурации, скрипты и инструменты Canon.
Тестирование типов
import type { Expect, IsFalse } from '@relational-fabric/canon'
import { invariant } from '@relational-fabric/canon'
interface Config {
apiUrl: string
timeout: number
}
// Документируем и обеспечиваем соблюдение ожиданий типов
void invariant<Expect<Config['apiUrl'], string>>()
void invariant<Expect<Config['timeout'], number>>()
void invariant<IsFalse<Expect<Config['timeout'], string>>>()
Ленивая типизация
import type { Canon, Satisfies } from '@relational-fabric/canon'
import { declareCanon, idOf, pojoWithOfType } from '@relational-fabric/canon'
// Определяем тип канона
type MyCanon = Canon<{
Id: { $basis: { id: string }; key: 'id' }
}>
// Регистрируем с помощью расширения модуля
declare module '@relational-fabric/canon' {
interface Canons { My: MyCanon }
}
// Объявляем конфигурацию среды выполнения
declareCanon('My', {
axioms: {
Id: { $basis: pojoWithOfType('id', 'string'), key: 'id' },
},
})
// Пишем универсальный код
function process<T extends Satisfies<'Id'>>(entity: T) {
return `Processing: ${idOf(entity)}`
}
Что дальше
Чтобы увидеть ленивую типизацию и тестирование типов в действии, изучите примеры в Canon.
Библиотека Canon — основа, но она является частью более масштабного видения.
Howard станет логическим движком: вычислимым механизмом истинности, где утверждения о данных станут объектами первого класса, которые можно компоновать, доказывать и кешировать. Это практическое воплощение соответствия Карри — Говард для логики среды выполнения.
Relational Fabric свяжет все это воедино: Filament — для мета-примитивов, Weft — для запросов, Warp — для хранения данных, Shuttle — для координации. Каждая библиотека будет решать свои задачи, при этом естественным образом интегрируясь с другими.
Canon — точка отсчета. Та самая каноническая нить, из которой будет соткана ткань.
Заключение
Библиотека Canon появилась в результате многолетней работы над одними и теми же основными элементами в разных проектах. Она кристаллизовалась, когда я осознал, что расширение интерфейсов (interface augmentation) может обеспечить ленивую типизацию: типы с поздней привязкой, которые адаптируются к зарегистрированным формам данных, сохраняя при этом безопасность на этапе компиляции.
Тестирование типов возникло как необходимость, потому что мне нужен был способ писать утверждения о типах, которые не пришлось бы выбрасывать. Они остаются в кодовой базе, не вызывают возмущения линтера, а, если типы начинают расходиться — компилятор немедленно это обнаруживает.
В результате получилась библиотека, которая предоставляет две возможности, необходимые каждому TypeScript-проекту:
- Тестирование типов: записывание своих ожиданий от типов, позволение компилятору обеспечивать их соблюдение — с нулевой стоимостью в среде выполнения.
- Ленивая типизация: определение семантических концепций, регистрирование форм данных, которые их реализуют, написание универсального кода.
Обе эти возможности являются выражением одной и той же философии, лежащей в основе Relational Fabric: эффективные абстракции служат UI для идей. Они позволяют воспринимать сложные системы как естественные. Canon — моя попытка реализовать эту философию для системы типов TypeScript.
Canon имеет открытый исходный код на github.com/RelationalFabric/canon и является частью экосистемы Relational Fabric.
Читайте также:
- Как профессионально использовать сопоставимые типы TypeScript
- TypeScript: от нулевого до продвинутого уровня. Часть 2
- TypeScript: разница между типами any и unknown
Читайте нас в Telegram, VK и Дзен
Перевод статьи Bahul Neel Upadhyaya: The End of Disposable Code: How I Built Universal APIs in TypeScript





