Генерация типов из дескрипторов

В экосистеме FormatJS типизация играет ключевую роль в обеспечении корректности работы с интернационализацией на уровне кода. Особенно это важно при использовании ICU Message Format, где структура сообщений может быть сложной и динамической. Одним из мощных инструментов становится генерация TypeScript-типов на основе дескрипторов сообщений, что позволяет связать runtime-локализацию с compile-time проверками.

Дескрипторы сообщений как источник типизации

В FormatJS сообщения обычно описываются в виде JSON-дескрипторов. Каждый дескриптор содержит метаданные, необходимые для рендеринга строки: идентификатор, дефолтный текст, описание и, при необходимости, параметры.

Пример дескриптора:

{
  "greeting": {
    "defaultMessage": "Hello, {name}!",
    "description": "Приветствие пользователя"
  }
}

В данном случае ключ greeting и параметр name являются кандидатами для автоматического извлечения типов. Цель генерации типов — превратить подобные структуры в строго типизированные контракты.


Зачем нужна генерация типов

Без генерации типов работа с FormatJS часто опирается на string-ключи и ручное управление параметрами. Это приводит к нескольким проблемам:

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

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


Основная идея типогенерации

Процесс генерации типов строится вокруг анализа структуры сообщений и извлечения:

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

Результатом становится TypeScript-модель, отражающая структуру локализационных данных.


Базовая форма сгенерированных типов

Для простого набора сообщений генерация может выглядеть следующим образом:

type Messages = {
  greeting: {
    defaultMessage: string;
    description: string;
  };
};

Однако более важным является уровень параметров сообщений.


Извлечение параметров из ICU-строк

FormatJS поддерживает ICU Message Syntax:

{
  "welcome": {
    "defaultMessage": "Hello, {name}, you have {count} messages"
  }
}

Из такого сообщения необходимо извлечь параметры name и count.

Сгенерированный тип для параметров:

type WelcomeParams = {
  name: string;
  count: number;
};

На практике тип count может быть уточнён в зависимости от контекста (например, number | string), но базовая модель строится именно через анализ ICU-выражений.


Алгоритм генерации типов

Генерация типов обычно проходит несколько этапов.

1. Парсинг сообщений

На первом этапе JSON-дескрипторы преобразуются в AST-подобную структуру. ICU-строка разбивается на:

  • текстовые сегменты;
  • интерполяции;
  • плурализацию;
  • селекторы.

Пример:

"{count, plural, one {1 message} other {# messages}}"

2. Извлечение переменных

Из AST выделяются переменные:

  • count как входной параметр;
  • контекст pluralization.

3. Определение типов

Для каждой переменной определяется тип:

  • числовые значения — number;
  • строковые идентификаторы — string;
  • даты — Date.

Если тип неявный, используется универсальный тип.


4. Построение TypeScript-структуры

На основе извлечённых данных формируется итоговый тип:

type MessageParams = {
  count: number;
};

Генерация объединённого словаря типов

В реальных приложениях используется единый тип для всех сообщений:

type IntlMessages = {
  greeting: { name: string };
  welcome: { count: number };
};

Такой подход позволяет централизовать контроль над всеми локализационными ключами.


Поддержка вложенных сообщений

FormatJS часто используется с вложенными структурами:

{
  "auth": {
    "login": {
      "title": "Sign in",
      "error": "Invalid credentials for {email}"
    }
  }
}

Типизация в этом случае должна отражать вложенность:

type IntlMessages = {
  auth: {
    login: {
      title: {};
      error: {
        email: string;
      };
    };
  };
};

Здесь пустой объект {} используется как маркер отсутствия параметров.


Генерация типов для react-intl

При использовании react-intl типизация становится особенно важной. Компонент <FormattedMessage> принимает параметры через values.

Пример:

<FormattedMessage
  id="welcome"
  values={{ name: "Alex", count: 5 }}
/>

Сгенерированный тип позволяет проверить корректность:

type WelcomeValues = {
  name: string;
  count: number;
};

Если передать лишний параметр или забыть обязательный, TypeScript выдаст ошибку.


Автоматическая генерация через CLI

В экосистеме FormatJS существует CLI-инструментарий, позволяющий автоматизировать процесс генерации.

Обычно процесс включает:

  • сканирование исходного кода;
  • поиск defineMessages;
  • сбор всех ICU-строк;
  • генерацию .d.ts файлов.

Пример результата:

declare namespace IntlMessages {
  export interface Messages {
    greeting: { name: string };
    welcome: { count: number };
  }
}

Интеграция с билд-системами

Генерация типов может быть встроена в пайплайн сборки:

  • Webpack loader;
  • Vite plugin;
  • Babel plugin;
  • отдельный CLI шаг.

Типичный сценарий:

  1. Сборка проекта;
  2. Анализ сообщений;
  3. Генерация типов;
  4. Проверка TypeScript.

Динамические ключи и ограничения типизации

Сложность возникает при использовании динамических ключей:

formatMessage({ id: dynamicKey });

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

Для таких сценариев применяется:

  • ограничение набора допустимых ключей через union types;
  • runtime-валидация;
  • fallback-сообщения.

Комбинирование с keyof и mapped types

TypeScript позволяет усиливать типизацию через mapped types:

type MessageKeys = keyof IntlMessages;

Это даёт возможность строго ограничить допустимые ID:

function formatMessage(id: MessageKeys, values?: any) {}

Таким образом исключаются несуществующие ключи.


Статическая проверка ICU-структур

Дополнительно генерация типов может учитывать структуру ICU-выражений:

  • наличие plural rules;
  • select expressions;
  • вложенные ICU-конструкции.

Пример сложного сообщения:

{
  "notifications": {
    "defaultMessage": "{count, plural, one {# notification} other {# notifications}}"
  }
}

Тип:

type NotificationsParams = {
  count: number;
};

Ограничения подхода

Несмотря на высокую полезность, генерация типов имеет ограничения:

  • сложность анализа вложенных ICU-конструкций;
  • неоднозначность типов параметров;
  • динамическая генерация ключей;
  • зависимость от структуры исходных JSON.

Эти ограничения требуют компромисса между строгостью и гибкостью.


Практика масштабирования типогенерации

В крупных приложениях используется разделение:

  • базовые сообщения (static);
  • доменные модули;
  • feature-based локализация.

Каждый модуль имеет собственный набор сгенерированных типов:

type AuthMessages = { ... };
type ProfileMessages = { ... };
type BillingMessages = { ... };

После этого они объединяются:

type AppMessages = AuthMessages & ProfileMessages & BillingMessages;

Синхронизация типов и переводов

Важным аспектом является поддержание синхронности:

  • удалённый ключ должен удаляться из типов;
  • новый ключ должен автоматически появляться;
  • изменения ICU должны отражаться в параметрах.

Это обеспечивает соответствие между кодом и переводами без ручного вмешательства.