Enforce same second level object type in TypeScript

Viewed 46

I'm trying to enforce having the same translation keys for every language with TypeScript.

const useTranslation<T extends TranslationObject> = (translations: T) => {
 ​// do something
}

const translations = {
  en: {
    button: "Hello World"
  },
  de: {
    button: "Hallo Welt"
  },
} ​

const invalidTranslations = {
  en: {
    button: "Hello World"
  },
  de: {
    foo: "Hallo Welt"
  },
} ​
// this should work
useTranslation(translations)

// this should give me a type error because typeof invalidTranslations["en"] !== typeof invalidTranslations["de"]
useTranslation(invalidTranslations)

I just can't figure out how the TranslationObject type should look in order to enforce the same type of all typeof translations[Language]. This is what I have so far:

const languages = ["de", "en"] as const;
type Language = typeof languages[number];
type Translation = { [key: string]: string | Translation };
type TranslationObject = { [K in Language]: Translation };

Note: I'd like to use TypeScript's inference for useTranslation and not explicitly provide any generic types. I do have a working example for providing the generic type:

type UnionToIntersection<T> = (T extends any ? (k: T) => void : never) extends (k: infer I) => void ? I : never;
type IsUnion<T> = [T] extends [UnionToIntersection<T>] ? true : false;
type VerifyTranslations<T extends TranslationObject> = IsUnion<T[Language]> extends true ? T : never;

// "Argument of type '[...]' is not assignable to parameter of type 'never'.ts(2345)"
useTranslation<VerifyTranslations<typeof invalidTranslations>>(invalidTranslations);
1 Answers

Here are 3 choices:

This will allow for any key

type Translation = {
    [x: string]: {
        button: string;
    }
}


/**
 * Any key is valid, but it will force the button property
 */
const translations: Translation = {
    "en": {
        button: ''
    }
}


const invalidTranslations: Translation = {
    "en": {
        button: ''
    }, 
    "es": {
        // Errors
        myCustomProp: ''
    }
}

These are options 2 and 3, giving you:

type SupportedLanguages = "en" | "es"
type Translations = {
    button: string;
    header: string;
}

type RequiredTranslation = {
    [x in SupportedLanguages]: {
        button: string;
        header: string;
    }
}


/**
 * This will require you to specify all of the keys found in 
 * SupportedLanguages, and will force you to specify all of 
 * the properties.
 */
const enhancedTranslations: RequiredTranslation= {
    "en": {
        button: "hello",
        header: "My header"
    },
    "es": {
        header: "Mi encabezado",
        button: "Mi botón"
    }
}

/**
 * Record will return a type similar to Translation above.
 */
type RecordSL = Record<SupportedLanguages, Translations>;
/**
 * By using Partial we tell TypeScript to make the properties 
 * optional.
 * 
 * This won't require any of them, but if they want to add one
 * then they will require to add the properties of the available 
 * translations
 */
type OptionRecord = Partial<RecordSL>


const optionalEnhancedTranslation: OptionRecord = {
    "en": {
        button: "My button",
        header: "My header"
    }
}

Edit (Added more to your original question): If you want to pass your translation object as a method expression, you can just do this:

// No need for generics:
const useTranslation = (translations: OptionRecord) => {
    ​// do something
}

Edit x2 (Dec. 30)

I've been hitting my head for over an hour now. Can't crack the nut. Either someone more experienced in TypeScript will need to help you out, or check it at runtime, and throw an error if they don't match.

The problem is that TypeScript (AFAIK) will need to know something about your object so it can enforce it.

Now, if you do enforce one language, such as "en", then everything becomes easier:

type Languages = "en" | "es";

type En = typeof a["en"];
type Translate = Partial<Record<Languages, En>>;


const a = {
  en: {
    button: {},
  },
  es: {
    button: {},
  },
};

const b = {
  en: {
    button: {},
  },
};

const c = {
  en: {
    button: {},
  },
  es: {
    header: {},
  },
};

function getLanguages(language: Translate) {}

// OK
getLanguages(a);
// OK
getLanguages(b);
// Will throw
getLanguages(c);

Of course, that solution will also be problematic if you're trying to expose your function as a library, as it won't work.

Related