Skip to content

Instantly share code, notes, and snippets.

@offirgolan
Last active September 19, 2025 17:24
Show Gist options
  • Select an option

  • Save offirgolan/51134b82f526aafd9a9dd9d112e3cc14 to your computer and use it in GitHub Desktop.

Select an option

Save offirgolan/51134b82f526aafd9a9dd9d112e3cc14 to your computer and use it in GitHub Desktop.
Extract ICU Message Argument Types
/**
* 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>;
@jrnail23
Copy link

@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 modifying MessageArguments like this:

export type MessageArguments<T extends string> =
  T extends `${infer _A}{${infer _B}}${infer _C}`
    ? ArgumentsMap<
        NormalizeArguments<ExtractArguments<StripEscaped<StripWhitespace<T>>>>
      >
    : undefined;

I threw together the following wrapper around FormattedMessage to validate, to ensure you can omit values when the defaultMessage has no arguments:

const FormattedMessage2 = <
  Msg extends string,
  Values extends MessageArguments<Msg>
>({
  defaultMessage,
  id,
  description,
  values,
}: Omit<MessageDescriptor, 'defaultMessage'> & {
  defaultMessage: Msg;
} & (Values extends undefined ? {values?: undefined} : {values: Values})) => (
  <FormattedMessage
    id={id}
    description={description}
    defaultMessage={defaultMessage}
    values={values}
  />
);

And here's what that looks like in action:

export const Experiment: FC = () => (
  <>
    <FormattedMessage2
      id="message2"
      defaultMessage={`{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!`}
      values={{
        name00: '',
        name0: '',
        name1: 0,
        name2: 0,
        numPhotos: 0,
        nested: new Date(),
        // this should probably be a union of the possible values
        gender: '',
        nested1: 0,
        year: 0,
        nested2: '',
      }}
    />
    <FormattedMessage2
      id="message3"
      defaultMessage="Message without arguments"
    />
  </>
);

@jrnail23
Copy link

FWIW, if you're interested in continuing to develop this, I'd love to help in any way I can.

@offirgolan
Copy link
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 | null for more flexible usage
  • String Number Support: Numeric formats accept both number and template literal `${number}` types
  • Comprehensive Select Matching: Select arguments with other clauses support string, number, boolean, and null
  • 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