Skip to content

Instantly share code, notes, and snippets.

@offirgolan
Last active July 11, 2023 04:20
Show Gist options
  • Save offirgolan/51134b82f526aafd9a9dd9d112e3cc14 to your computer and use it in GitHub Desktop.
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment