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
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 }}
.