В экосистеме FormatJS переводы обычно представлены как набор сообщений с идентификаторами, которые связываются с локализованными строками через ICU MessageFormat. В JavaScript это часто выглядит как объект или JSON-каталог, где ключи строковые и не дают статической гарантии корректности.
Типичная проблема возникает на стыке TypeScript и динамических переводов:
Извлечение типов из переводов в FormatJS решает задачу построения строгой связи между:
react-intl,
intl.formatMessage).Сообщение в 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"
Это базовый строительный блок типобезопасных переводов.
В реальных проектах часто используется отдельный 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 становятся
независимыми.
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;
Это связывает:
В реальных проектах переводы часто хранятся в JSON:
{
"unread": "You have {count} unread messages",
"welcome": "Hello, world!"
}
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 выполняют важную роль в синхронизации переводов:
Хотя CLI не всегда генерирует TypeScript-типы напрямую, он является основой для построения типобезопасного слоя поверх i18n-каталогов.
Типизация может быть расширена проверкой корректности ICU-форм:
type IsValidICU<S extends string> =
S extends `${string}{${string}}${string}`
? true
: false;
Это не покрывает весь стандарт ICU, но позволяет отлавливать базовые ошибки:
FormatJS поддерживает сложные конструкции ICU:
{count, plural, one {1 item} other {# items}}
Типизация таких выражений усложняется, поскольку параметры:
Упрощённая модель:
type PluralParams = {
count: number;
};
Более сложные модели требуют парсинга ICU 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;
};
Несмотря на мощные возможности TypeScript, остаются ограничения:
Поэтому типизация чаще выступает как слой проверки, а не абсолютная гарантия.
На практике используется комбинированная модель:
as const или defineMessages для фиксации
литералов;keyof typeof;Такая архитектура позволяет удерживать баланс между: