Типобезопасное использование интерполяции

Базовые принципы интерполяции в ICU MessageFormat

В экосистеме FormatJS интерполяция реализуется через ICU MessageFormat — формат строк, поддерживающий именованные параметры, множественные формы, выбор и вложенные конструкции.

Типичный шаблон сообщения:

{
  greeting: "Привет, {name}"
}

Интерполяция выполняется через передачу объекта значений:

intl.formatMessage(
  { id: "greeting", defaultMessage: "Привет, {name}" },
  { name: "Алексей" }
)

Ключевая проблема на этом уровне — отсутствие гарантий соответствия между шаблоном и передаваемыми значениями. Ошибки проявляются только во время выполнения: опечатки в ключах, неправильные типы, пропущенные параметры.


Источник типобезопасности: связка TypeScript и сообщений

Типобезопасность в FormatJS достигается через статическое описание сообщений и генерацию типов.

Основная модель строится вокруг MessageDescriptor:

import { MessageDescriptor } from "react-intl";

const messages: Record<string, MessageDescriptor> = {
  greeting: {
    id: "greeting",
    defaultMessage: "Привет, {name}"
  }
};

Однако использование Record<string, MessageDescriptor> ослабляет строгую проверку ключей. Более строгий подход — сохранение литеральных типов.


Сохранение литеральных ключей сообщений

Ключевым элементом типобезопасности становится фиксация структуры сообщений через as const:

export const messages = {
  greeting: {
    id: "greeting",
    defaultMessage: "Привет, {name}"
  },
  unread: {
    id: "unread",
    defaultMessage: "Новых сообщений: {count}"
  }
} as const;

Такой подход сохраняет:

  • точные строковые литералы ключей
  • неизменяемость структуры
  • возможность извлечения типов через typeof

Типизация параметров интерполяции

На основе ICU-шаблонов возможно построение типов значений.

Пример базовой типизации:

type MessageValues = {
  greeting: { name: string };
  unread: { count: number };
};

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

function format<K extends keyof MessageValues>(
  key: K,
  values: MessageValues[K]
) {
  return intl.formatMessage(messages[key], values);
}

Данный подход обеспечивает:

  • проверку существования ключа сообщения
  • контроль структуры интерполяционных параметров
  • предотвращение передачи лишних значений

Автоматическая генерация типов сообщений

Ручное описание типов не масштабируется, поэтому применяется генерация через CLI инструменты FormatJS.

Используется @formatjs/cli:

formatjs extract "src/**/*.{ts,tsx}" --out-file messages.json

Далее генерируются TypeScript-типы:

formatjs compile messages.json --out-file messages.ts

Результат включает строго типизированные ключи и значения интерполяции.


Инференс типов через defineMessages

Более интегрированный подход основан на defineMessages:

import { defineMessages } from "react-intl";

const messages = defineMessages({
  greeting: {
    id: "greeting",
    defaultMessage: "Привет, {name}"
  },
  unread: {
    id: "unread",
    defaultMessage: "Новых сообщений: {count}"
  }
});

TypeScript способен извлечь структуру:

type Keys = keyof typeof messages;

Однако типизация значений интерполяции требует дополнительной обвязки или генераторов.


Строгая проверка интерполяции через шаблонные типы

Для повышения точности применяется извлечение параметров из строк ICU.

Пример утилитарного типа:

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

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

type GreetingMessage = "Привет, {name}";

type GreetingParams = Record<ExtractParams<GreetingMessage>, string>;

Результат:

  • name становится обязательным ключом
  • любые другие ключи запрещены на уровне типов

Защита от несоответствия типов значений

Ошибка возникает при передаче неверного типа:

intl.formatMessage(messages.unread, {
  count: "пять" // ошибка: ожидается number
});

Типизация значений устраняет класс ошибок:

  • строка вместо числа
  • отсутствие обязательного параметра
  • лишние поля объекта

Интерполяция с опциональными параметрами

ICU поддерживает опциональные значения через условия и fallback:

{
  welcome: "Привет, {name, select, undefined {гость} other {{name}}}"
}

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

  • обязательным
  • условно используемым
  • частью sel ect-expression

В строгих схемах используется расширенный тип:

type NullableParams<T> = {
  [K in keyof T]?: T[K] | undefined;
};

Числовая интерполяция и форматирование типов

FormatJS различает интерполяцию и форматирование.

Пример:

{
  price: "Цена: {value, number, currency}"
}

Типизация:

type PriceParams = {
  value: number;
};

Важно разделение:

  • тип значения (number)
  • формат отображения (currency, percent, unit)

Формат не влияет на тип, но влияет на runtime-обработку.


Проблема слабой типизации при использовании intl.formatMessage

Стандартная сигнатура:

intl.formatMessage(descriptor, values?)

Проблемы:

  • values имеет тип Record<string, any>
  • отсутствует связь с конкретным сообщением
  • невозможность проверки ключей

Решение — обёртка с дженериками:

function typedFormat<K extends keyof MessageValues>(
  key: K,
  values: MessageValues[K]
) {
  return intl.formatMessage(messages[key], values);
}

Связь с React Intl и типизация пропсов

В контексте React используется:

import { useIntl } fr om "react-intl";

const Component = () => {
  const intl = useIntl();

  return intl.formatMessage(messages.greeting, {
    name: "Мария"
  });
};

При строгой типизации формируется контракт:

  • сообщения определяют структуру данных
  • компонент не может передать лишние параметры
  • отсутствуют необъявленные ключи интерполяции

Извлечение параметров ICU через AST

Более надёжный метод типизации основан на AST-парсинге ICU-строк.

FormatJS предоставляет парсер сообщений, формирующий структуру:

  • MessageFormatElement
  • LiteralElement
  • ArgumentElement
  • SelectElement
  • PluralElement

Из этой структуры извлекаются аргументы:

import { parse } from "@formatjs/icu-messageformat-parser";

Далее возможно построение строгих типов на основе AST:

  • анализ ArgumentElement
  • извлечение ключей
  • проверка дубликатов
  • контроль вложенных select/plural

Ограничения статической типизации интерполяции

Типобезопасность в FormatJS не покрывает полностью динамические сценарии:

  • runtime-загрузка переводов
  • внешние JSON-файлы без генерации типов
  • динамические ключи сообщений
  • fallback-цепочки с неизвестной структурой

В этих случаях применяется частичная типизация:

type SafeValues = Record<string, string | number | boolean | null>;

Комбинация генерации и строгих контрактов

Наиболее устойчивый подход строится на связке:

  • extract сообщений из кода
  • генерация типизированных ресурсов
  • использование as const для локальных модулей
  • строгие дженерики для formatMessage
  • AST-проверка ICU-структур

Результат — замкнутый контур типизации:

  • сообщение определяет форму данных
  • TypeScript проверяет соответствие
  • runtime выполняет интерполяцию без ошибок типов