Search code examples
typescripttypescript-generics

Generic object interface with matching keys


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?


Solution

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

    Playground link to code