Last active
September 19, 2025 17:24
-
-
Save offirgolan/51134b82f526aafd9a9dd9d112e3cc14 to your computer and use it in GitHub Desktop.
Extract ICU Message Argument Types
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Utility type to replace a string with another. | |
| */ | |
| type Replace<S extends string, R extends string, W extends string> = | |
| S extends `${infer BS}${R}${infer AS}` | |
| ? Replace<`${BS}${W}${AS}`, R, W> | |
| : S | |
| /** | |
| * Utility type to remove all spaces and new lines from the provided string. | |
| */ | |
| type StripWhitespace<S extends string> = Replace<Replace<S, '\n', ''>, ' ', ''>; | |
| /** | |
| * Utility type to remove escaped characters. | |
| * | |
| * @example "'{word}" -> "word}" | |
| * @example "foo '{word1} {word2}'" -> "foo " | |
| */ | |
| type StripEscaped<S extends string> = | |
| S extends `${infer A}'${string}'${infer B}` ? StripEscaped<`${A}${B}`> : | |
| S extends `${infer A}'${string}${infer B}` ? StripEscaped<`${A}${B}`> : | |
| S; | |
| /** | |
| * Extract ICU message arguments from the given string. | |
| */ | |
| type ExtractArguments<S extends string> = | |
| /* Handle {arg0,selectordinal,...}} since it has nested {} */ | |
| S extends `${infer A}{${infer B}}}${infer C}` | |
| ? ExtractArguments<A> | _ExtractComplexArguments<B> | ExtractArguments<C> : | |
| /* Handle remaining arguments {arg0}, {arg0, number}, {arg0, date, short}, etc. */ | |
| S extends `${infer A}{${infer B}}${infer C}` | |
| ? ExtractArguments<A> | B | ExtractArguments<C> : | |
| never; | |
| /** | |
| * Handle complex type argument extraction (i.e plural, select, and selectordinal) which | |
| * can have nested arguments. | |
| */ | |
| type _ExtractComplexArguments<S extends string> = | |
| /* Handle arg0,plural,... */ | |
| S extends `${infer A},plural,${infer B}` | |
| ? ExtractArguments<`{${A},plural}`> | _ExtractNestedArguments<`${B}}`> : | |
| /* Handle arg0,select,... */ | |
| S extends `${infer A},select,${infer B}` | |
| ? ExtractArguments<`{${A},select}`> | _ExtractNestedArguments<`${B}}`> : | |
| /* Handle arg0,selectordinal,... */ | |
| S extends `${infer A},selectordinal,${infer B}` | |
| ? ExtractArguments<`{${A},selectordinal}`> | _ExtractNestedArguments<`${B}}`> : | |
| never | |
| /** | |
| * Extract nested arguments from complex types such as plural, select, and selectordinal. | |
| */ | |
| type _ExtractNestedArguments<S extends string> = S extends `${infer A}{${infer B}}${infer C}` | |
| ? _ExtractNestedArguments<A> | ExtractArguments<`${B}}`> | _ExtractNestedArguments<C> : | |
| never; | |
| /** | |
| * Normalize extract arguments to either `name` or `name,type`. | |
| */ | |
| type NormalizeArguments<TArg extends string> = | |
| /* Handle "name,type,other args" */ | |
| TArg extends `${infer Name},${infer Type},${string}` ? `${Name},${Type}` : | |
| /* Handle "name,type" */ | |
| TArg extends `${infer Name},${infer Type}` ? `${Name},${Type}` : | |
| /* Handle "name" */ | |
| TArg; | |
| /** | |
| * Convert ICU type to TS type. | |
| */ | |
| type Value<T extends string> = | |
| T extends 'number' | 'plural' | 'selectordinal' ? number : | |
| T extends 'date' | 'time' ? Date : | |
| string; | |
| /** | |
| * Create an object mapping the extracted key to its type. | |
| */ | |
| type ArgumentsMap<S extends string> = { | |
| [key in S extends `${infer Key},${string}` ? Key : S]: Extract<S, `${key},${string}`> extends `${string},${infer V}` ? Value<V>: string; | |
| } | |
| /** | |
| * Create an object mapping all ICU message arguments to their types. | |
| */ | |
| type MessageArguments<T extends string> = ArgumentsMap<NormalizeArguments<ExtractArguments<StripEscaped<StripWhitespace<T>>>>>; | |
| /* ======================= */ | |
| const message1 = '{name00} Foo bar {name0} baz {name1, number} bars {name2, number, ::currency} foos{name3, date, short}' | |
| const message2 = `{name00} Foo bar {name0} baz {name1, number} bars {name2, number, ::currency} You have {numPhotos, plural, | |
| =0 {no photos {nested, date, short}.} | |
| =1 {one photo.} | |
| other {# photos.} | |
| }. {gender, select, | |
| male {He {nested1, number}} | |
| female {She} | |
| other {They} | |
| } will respond shortly. It's my cat's {year, selectordinal, | |
| one {#st {nested2}} | |
| two {#nd} | |
| few {#rd} | |
| other {#th} | |
| } birthday!` | |
| const message3 = "Message without arguments"; | |
| const message4 = "{count, plural, =0 {} =1 {We accept {foo}.} other {We accept {bar} and {foo}.}}"; | |
| const message5 = `{gender, select, | |
| male {He {nested1, number}} | |
| female {She} | |
| other {They} | |
| } will respond shortly.` | |
| const message6 = `It's my cat's {year, selectordinal, | |
| one {#st {nested2}} | |
| two {#nd} | |
| few {#rd} | |
| other {#th} | |
| } birthday!` | |
| const message7 = `{name00} Foo bar {name0} baz {name1, number} This '{isn''t}' obvious. '{name2, number, ::currency}' foos'{name3, date, short}` | |
| const message8 = `Our price is <boldThis>{price, number, ::currency/USD precision-integer}</boldThis> | |
| with <link>{pct, number, ::percent} discount</link>` | |
| type Arguments1 = MessageArguments<typeof message1>; | |
| type Arguments2 = MessageArguments<typeof message2>; | |
| type Arguments3 = MessageArguments<typeof message3>; | |
| type Arguments4 = MessageArguments<typeof message4>; | |
| type Arguments5 = MessageArguments<typeof message5>; | |
| type Arguments6 = MessageArguments<typeof message6>; | |
| type Arguments7 = MessageArguments<typeof message7>; | |
| type Arguments8 = MessageArguments<typeof message8>; |
FWIW, if you're interested in continuing to develop this, I'd love to help in any way I can.
Author
👋 Hi there, its taken me a while to come back to this but I ended up completely overhauling the original implementation and created a types-only NPM package (icu-message-types).
import type { ICUMessageArguments, ICUMessageTags } from 'icu-message-types';
// Extract argument types
type Args0 = ICUMessageArguments<'Hello, {firstName} {lastName}!'>;
// Result: { firstName: string | number | boolean; lastName: string | number | boolean }
type Args1 = ICUMessageArguments<`{theme, select,
light {The interface will be bright}
dark {The interface will be dark}
other {The interface will use default colors}
}`>;
// Result: { theme: 'light' | 'dark' | ({} & string) | ({} & number) | boolean | null }
// Extract tag names
type Tags = ICUMessageTags<'Click <link>here</link> to continue'>;
// Result: 'link'Message Arguments
| Format | TypeScript Type | Example |
|---|---|---|
string |
string | number | boolean | null |
{name} |
number |
number | `${number}` | null |
{count, number, ...} |
date |
Date | number | `${number}` | null |
{date, date, short} |
time |
Date | number | `${number}` | null |
{time, time, medium} |
plural |
number | `${number}` | null |
{count, plural, one {...} other {...}} |
selectordinal |
number | `${number}` | null |
{position, selectordinal, one {#st} other {#th}} |
select |
union | string | number | boolean | null |
{theme, select, light {...} dark {...} other {...}} |
Additional Features
- Enhanced Value Types: Non-formatted arguments accept
string | number | boolean | nullfor more flexible usage - String Number Support: Numeric formats accept both
numberand template literal`${number}`types - Comprehensive Select Matching: Select arguments with
otherclauses supportstring,number,boolean, andnull - Literal Type Transformation: Select keys are intelligently transformed (e.g.,
'123'becomes'123' | 123,'true'becomes'true' | true) - Escaped content: Properly handles quoted/escaped text that shouldn't be parsed as arguments
- Nested messages: Supports complex nested structures
- Whitespace handling: Automatically strips whitespace, new lines, and tabs for improved parsing
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@offirgolan, this is awesome.
I played around with it and found that
"Message without arguments"didn't quite work as expected, so I (very naively) fixed it by modifyingMessageArgumentslike this:I threw together the following wrapper around
FormattedMessageto validate, to ensure you can omitvalueswhen thedefaultMessagehas no arguments:And here's what that looks like in action: