typescripttypescript-genericsnext-intl

Update: Typescript Utility Function Merge Object Key Path


Ok, I appreciate the comments so far, let me reframe the question more specifically.

I have the following setup:

type AbstractIntlMessages = {
    [id: string]: AbstractIntlMessages | string;
};

interface Translations<Locales extends string, Messages extends AbstractIntlMessages = AbstractIntlMessages> {
  Messages: Record<Locales, () => Promise<{ default: Messages }>>;
}

async function combineTranslations<
  Locales extends string,
  Messages extends AbstractIntlMessages = AbstractIntlMessages
>(locale: Locales, definitions: Array<Translations<Locales>>): Promise<Messages> {
  const imported = await Promise.all(definitions.map((definition) => definition.Messages[locale]()));
  const translations = imported.map((t) => t.default);
  return Object.assign({}, ...translations);
}

const SiteTranslations = {
  Messages: {
    en_US: () => Promise.resolve({ default: { Site: { title: 'My Site' } } }),
    es_US: () => Promise.resolve({ default: { Site: { title: 'Mi Sitio' } } }),
  }
};

const AuthFeatureTranslations = {
  Messages: {
    en_US: () => Promise.resolve({ default: { Auth: { title: 'My Auth' } } }),
    es_US: () => Promise.resolve({ default: { Auth: { title: 'Mi Autentico' } } }),
  }
};

const locales = ['en_US', 'es_US'] as const;
type Locales = (typeof locales)[number];

async function i18n(locale: Locales) {
  const messages = await combineTranslations(locale, [
    SiteTranslations,
    AuthFeatureTranslations
  ]);

  console.log(messages)
}

i18n('en_US')

After running combineTranslations the type of messages is AbstractIntlMessages.

How do I structure the types of combineTranslations such that the type of messages is:

{
  Site: {
    title: string;
  };
  Auth: {
    title: string;
  };
}

I am using next-intl which uses the types of messages to drive auto-complete and type validation during dev. Right now that is not working because the type of messages is AbstractIntlMessages.

I'll also note, it does not matter if the attributes of messages are readonly or not. Additionally, the leaf attributes of messages can either be string or their actual string values from the translations.

Here is a link to a TS Playground with the code: https://tsplay.dev/w8qj4N


Solution

  • You could give combineTranslations() the following call signature:

    async function combineTranslations<
        L extends Locales,
        const M extends AbstractIntlMessages[]
    >(
        locale: L,
        definitions: { [I in keyof M]: Translations<L, M[I]> }
    ): Promise<{ [I in keyof M]: (x: M[I]) => void } extends
        { [k: number]: (x: infer U) => void } ? { [P in keyof U]: U[P] } : never
    > {
        const imported = await Promise.all(definitions.map(
          (definition) => definition.Messages[locale]()));
        const translations = imported.map((t) => t.default);
        return Object.assign({}, ...translations);
    }
    

    That's generic in two type parameters: L, corresponding to the type of the locale parameter; and M, corresponding to the array of AbstractIntlMessages subtypes relevant for the array of definition. So if you call combineTranslations("en_US", [SiteTranslations, AuthFeatureTranslations]), we expect L to be "en_US" and M to be [{ Site: { title: string }}, { Auth: { title: string }}].

    Note that the type of definitions isn't directly M, but a homomorphic mapped array/tuple type over M (see What does "homomorphic mapped type" mean?) of the form { [I in keyof M]: Translations<L, M[I]> }. That means, for each element at numeric-like index I of the M array (of the indexed access type M[I]), the corresponding element of the definitions array will be of type Translations<L, M[I]>. TypeScript can infer from homomorphic mapped types, so the intent is that M will be inferrable from definitions.

    Oh, and note that M is a const type parameter to hint to the compiler that we'd like M to be inferred as a tuple instead of as an unordered array.

    Once we have L and M, then the return type is Promise<{ [I in keyof M]: (x: M[I]) => void } extends { [k: number]: (x: infer U) => void } ? { [P in keyof U]: U[P] } : never> which is essentially Promise<TupleToIntersection<M>>, where TupleToIntersection is a hypothetical utility type that takes a tuple like [X, Y, Z] and converts it into an intersection like X & Y & Z. It uses a technique similar to that shown in Transform union type to intersection type. So if M is [{ Site: { title: string }}, { Auth: { title: string }}], then the output type should be Promise<{ Site: { title: string }} & { Auth: { title: string }}> which is equivalent to the desired Promise<{ Site: { title: string }; Auth: { title: string }}> type where the intersection has been "flattened" into a single object type. In case it matters, the inferred U type is the intersection of the elements of M, and the mapped type { [P in keyof U]: U[P] } is the "flattened" version of U.


    Okay, let's test it out:

    const messages = await combineTranslations(locale, [
        SiteTranslations,
        AuthFeatureTranslations
    ]);
    /* function combineTranslations<
         "en_US" | "es_US", 
         [{ Site: { title: string; };}, { Auth: { title: string; };}]
       > */
    messages.Auth.title.toUpperCase();
    messages.Site.title.toLowerCase();
    

    Looks good. The type of messages is the desired { Site: { title: string }, Auth: { title: string }}.

    Playground link to code