Долгий путь к канону

У каждого разработчика есть шаблоны, используемые им в разных проектах. Код, который переписывается и дорабатывается до тех пор, пока не станет привычным. Для меня таким шаблоном был набор абстракций данных: способы представления об идентичности, типах, отношениях и изменениях во времени.

Создав эти абстракции в достаточном количестве проектов, я решил найти им подходящее место. Так появилась 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 — в третьем. У вас есть два варианта:

  • сделать утверждения эквивалентными (написать утверждения с учетом формата, которые обрабатывают все вариации);
  • сделать данные эквивалентными (нормализовать до канонических концепций).

Первый вариант приводит к утверждениям, изобилующим условными конструкциями, или к отдельному утверждению для каждого формата. Ни один из них не подходит. Второй вариант означает установление канонических представлений, на которые утверждения могут ориентироваться единообразно.

Я выбрал второй вариант. Но «сделать данные эквивалентными» может означать две разные вещи:

  1. Каноническая форма (нормализованная нотация): преобразовать все данные в единое представление. Каждый объект получает поле id, и точка. Это требует преобразования на границах системы и приводит к потере информации об исходных данных.
  1. Канонический 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-проекту:

  1. Тестирование типов: записывание своих ожиданий от типов, позволение компилятору обеспечивать их соблюдение — с нулевой стоимостью в среде выполнения.
  1. Ленивая типизация: определение семантических концепций, регистрирование форм данных, которые их реализуют, написание универсального кода.

Обе эти возможности являются выражением одной и той же философии, лежащей в основе Relational Fabric: эффективные абстракции служат UI для идей. Они позволяют воспринимать сложные системы как естественные. Canon — моя попытка реализовать эту философию для системы типов TypeScript.

Canon имеет открытый исходный код на github.com/RelationalFabric/canon и является частью экосистемы Relational Fabric.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Bahul Neel Upadhyaya: The End of Disposable Code: How I Built Universal APIs in TypeScript

Предыдущая статьяЯ понял разницу между SQL и NoSQL — и мой бэкенд заработал быстрее