I'm trying to make a generic interface to define a translation dictionary where the primary keys specify the language, and within each language the same keys are present.
Here's my attempt so far using generics (preferable):
interface TranslationDict<K extends string, O extends K[number]> {
eng: Record<K, string>;
jpn: Record<O, string>;
}
const translationDict: TranslationDict<string, string> = {
eng: {
year: "year",
month: "month",
day: "day",
file: "file",
},
jpn: {
year: "年",
month: "月",
day: "日",
file: "ファイル",
file2: "bla", // I want this to throw a type error
},
};
Without generics, this works, but seems like way too much boilerplate:
const cols = ["year", "month", "day", "file"] as const;
export const commonCols: {
eng: Record<(typeof cols)[number], string>;
jpn: Record<(typeof cols)[number], string>;
} = {
eng: {
year: "year",
month: "month",
day: "day",
file: "file",
},
jpn: {
year: "年",
month: "月",
day: "日",
file: "ファイル",
file2: "bla", // TYPE ERROR
},
};
How do I use generics to constrain the keys of jpn
to be the same as the keys of eng
?
You do want to use a generic type like TranslationDict<K>
:
interface TranslationDict<K extends string> {
eng: Record<K, string>,
jpn: Record<K, string>
}
but then when you annotate the type, you need to write out the type argument manually:
const dict: TranslationDict<"year" | "month" | "day" | "file"> = {
eng: {
year: "year",
month: "month",
day: "day",
file: "file",
},
jpn: {
year: "年",
month: "月",
day: "日",
file: "ファイル",
file2: "bla", // error!
//~~~ <-- Object literal may only specify known properties
// but 'file2' does not exist in type
// 'Record<"year" | "month" | "day" | "file", string>'
},
};
This gives you the excess property error you want, but it's redundant because you're forced to write "year" | "month" | "day" | "file"
yourself. You really want the compiler to infer that from eng
, perhaps like
// don't write this, not valid
const dict: TranslationDict<infer> = { ⋯ };
but that's not currently supported. You can't infer a type argument for a generic type. There's a feature request at microsoft/TypeScript#32794, but until and unless that's implemented you need another approach.
The most common way of getting behavior like this is to write a generic function to help. TypeScript does infer generic type arguments when you call a generic function, so we can use that inference instead. The helper function is just an identity like d => d
at runtime. Its only purpose is to aid with inference.
Like this:
const translationDict = <K extends string>(
d: TranslationDict<K>) => d;
And then instead of annotating, you call the function:
const dict = translationDict({
eng: { // error!
year: "year",
month: "month",
day: "day",
file: "file",
},
jpn: {
year: "年",
month: "月",
day: "日",
file: "ファイル",
file2: "bla",
},
});
That works, but the error isn't where you wanted it to be. The compiler ends up inferring K
as "year" | "month" | "day" | "file" | "file2"
and then complains about eng
being wrong. Maybe that suffices for your needs.
But if you want the compiler to only infer K
from eng
and then just check K
against jpn
, you could use the NoInfer
utility type to block inference from the utterance of K
in the jpn
property. That is, change TranslationDict<K>
to
interface TranslationDict<K extends string> {
eng: Record<K, string>,
jpn: Record<NoInfer<K>, string>
}
And once you do this, you finally get the desired behavior as specified:
const dict = translationDict({
eng: {
year: "year",
month: "month",
day: "day",
file: "file",
},
jpn: {
year: "年",
month: "月",
day: "日",
file: "ファイル",
file2: "bla", // error!
//~~~ <-- Object literal may only specify known properties
// but 'file2' does not exist in type
// 'Record<"year" | "month" | "day" | "file", string>'
},
});
Now K
is inferred as "year" | "month" | "day" | "file"
and thus the object literal's eng
property is fine, but jpn
has an excess file2
property.