Извлечение типов из переводов

В экосистеме FormatJS переводы обычно представлены как набор сообщений с идентификаторами, которые связываются с локализованными строками через ICU MessageFormat. В JavaScript это часто выглядит как объект или JSON-каталог, где ключи строковые и не дают статической гарантии корректности.

Типичная проблема возникает на стыке TypeScript и динамических переводов:

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

Извлечение типов из переводов в FormatJS решает задачу построения строгой связи между:

  • идентификаторами сообщений;
  • параметрами ICU-строк;
  • API компонентов форматирования (react-intl, intl.formatMessage).

Базовая модель сообщений FormatJS

Сообщение в FormatJS обычно описывается через MessageDescriptor:

import { MessageDescriptor } from 'react-intl';

const messages: Record<string, MessageDescriptor> = {
  welcome: {
    id: 'welcome',
    defaultMessage: 'Hello, world!'
  },
  unread: {
    id: 'unread',
    defaultMessage: 'You have {count} unread messages'
  }
};

Проблема этого подхода заключается в том, что TypeScript видит только string ключи, а не конкретные литералы welcome | unread.


Извлечение литеральных ключей через as const

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

const messages = {
  welcome: {
    id: 'welcome',
    defaultMessage: 'Hello, world!'
  },
  unread: {
    id: 'unread',
    defaultMessage: 'You have {count} unread messages'
  }
} as const;

После этого TypeScript способен вывести:

type MessageKey = keyof typeof messages;
// "welcome" | "unread"

Это базовый строительный блок типобезопасных переводов.


Разделение ключей и ICU-идентификаторов

В реальных проектах часто используется отдельный id, а ключ объекта служит алиасом:

const messages = {
  welcome: {
    id: 'app.welcome',
    defaultMessage: 'Hello, world!'
  },
  unread: {
    id: 'app.unread',
    defaultMessage: 'You have {count} unread messages'
  }
} as const;

В этом случае типизация ключей остаётся на уровне keyof typeof messages, но реальные runtime-id становятся независимыми.


Типизация параметров ICU сообщений

FormatJS использует ICU MessageFormat, где параметры внутри {} должны передаваться при форматировании.

Пример:

"You have {count} unread messages"

Ручная типизация параметров

type MessageValues = {
  unread: {
    count: number;
  };
};

Использование:

intl.formatMessage(messages.unread, {
  count: 5
});

Минус подхода — ручная синхронизация типов и строк.


Выведение параметров из шаблона сообщений

Ключевой шаг — автоматическое извлечение параметров из ICU-строк.

В TypeScript это обычно делается через условные типы и шаблонные строки.

Базовый тип извлечения плейсхолдеров

type ExtractParams<S extends string> =
  S extends `${string}{${infer Param}}${infer Rest}`
    ? Param | ExtractParams<Rest>
    : never;

Применение:

type Params = ExtractParams<'You have {count} unread messages'>;
// "count"

Построение типизированного каталога сообщений

Объединение ключей и параметров:

type Messages = typeof messages;

type MessageIds = keyof Messages;

Теперь можно связать параметры с конкретным сообщением:

type MessageParams<K extends MessageIds> =
  Messages[K] extends { defaultMessage: infer M }
    ? M extends string
      ? Record<ExtractParams<M>, unknown>
      : never
    : never;

Использование:

type UnreadParams = MessageParams<'unread'>;
// { count: unknown }

Усиление типизации параметров

Часто unknown заменяется на более точные типы через явные аннотации:

const messages = {
  unread: {
    id: 'app.unread',
    defaultMessage: 'You have {count} unread messages',
    values: null as unknown as { count: number }
  }
} as const;

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


Автоматическое выведение через типизированные фабрики

Более строгая модель строится через функцию определения сообщений:

function defineMessages<T extends Record<string, { defaultMessage: string }>>(m: T) {
  return m;
}

Использование:

const messages = defineMessages({
  unread: {
    defaultMessage: 'You have {count} unread messages'
  }
});

Теперь:

type Keys = keyof typeof messages;

И возможна дальнейшая композиция с ExtractParams.


Типизация react-intl и useIntl

В react-intl основная точка взаимодействия — formatMessage:

intl.formatMessage(descriptor, values?)

Можно усилить типизацию через перегрузки:

type TypedFormatMessage<M> = <
  K extends keyof M & string
>(
  descriptor: M[K],
  values: MessageParams<K>
) => string;

Это связывает:

  • ключ сообщения;
  • структуру параметров;
  • конкретный ICU шаблон.

Генерация типов из JSON-каталогов

В реальных проектах переводы часто хранятся в JSON:

{
  "unread": "You have {count} unread messages",
  "welcome": "Hello, world!"
}

Выведение типов из JSON

import messages from './messages.json';

type MessageKeys = keyof typeof messages;

Параметры:

type MessageValues<K extends MessageKeys> =
  typeof messages[K] extends string
    ? Record<ExtractParams<typeof messages[K]>, unknown>
    : never;

Роль @formatjs/cli в экосистеме типов

CLI-инструменты FormatJS выполняют важную роль в синхронизации переводов:

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

Хотя CLI не всегда генерирует TypeScript-типы напрямую, он является основой для построения типобезопасного слоя поверх i18n-каталогов.


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

Типизация может быть расширена проверкой корректности ICU-форм:

type IsValidICU<S extends string> =
  S extends `${string}{${string}}${string}`
    ? true
    : false;

Это не покрывает весь стандарт ICU, но позволяет отлавливать базовые ошибки:

  • несбалансированные скобки;
  • пустые параметры;
  • некорректные вложения.

Типизация pluralization и select-выражений

FormatJS поддерживает сложные конструкции ICU:

{count, plural, one {1 item} other {# items}}

Типизация таких выражений усложняется, поскольку параметры:

  • используются внутри блоков;
  • имеют зависимость от категории plural rules.

Упрощённая модель:

type PluralParams = {
  count: number;
};

Более сложные модели требуют парсинга ICU AST, где строка преобразуется в дерево выражений.


AST как источник строгих типов

ICU MessageFormat можно разобрать в AST через парсер FormatJS. В этом случае:

  • строка превращается в структуру MessageFormatElement;
  • из неё извлекаются переменные;
  • строится точный тип параметров.

Псевдомодель:

type ASTToParams<T> = T extends { type: 'argument'; value: infer V }
  ? V
  : never;

Такой подход используется в генераторах типов и кодогенерации.


Композиция типов в больших проектах

В масштабных приложениях формируется единая модель:

  • MessageKeys — объединение всех ключей;
  • MessageValues — соответствие ключ → ICU строка;
  • MessageParams — параметры по ключу;
  • TypedIntl — обёртка над formatMessage.
type TypedIntl<M extends Record<string, { defaultMessage: string }>> = {
  formatMessage<K extends keyof M>(
    key: K,
    values: MessageParams<K>
  ): string;
};

Ограничения типизации в FormatJS

Несмотря на мощные возможности TypeScript, остаются ограничения:

  • ICU-строки слишком динамичны для полного статического анализа;
  • plural/select правила зависят от локали;
  • JSON-каталоги могут быть внешними и недоступными во время компиляции;
  • runtime FormatJS остаётся источником истины.

Поэтому типизация чаще выступает как слой проверки, а не абсолютная гарантия.


Практическая архитектура типобезопасных переводов

На практике используется комбинированная модель:

  • JSON или TS-каталог как источник;
  • as const или defineMessages для фиксации литералов;
  • извлечение ключей через keyof typeof;
  • частичное извлечение параметров через шаблонные типы;
  • optional codegen для сложных ICU выражений.

Такая архитектура позволяет удерживать баланс между:

  • гибкостью i18n;
  • безопасностью TypeScript;
  • совместимостью с FormatJS runtime.