Generate type by object in string format in typescript [duplicate]

I want to strongly type a dot separated path to a specific type. Let's say we have a recursive structure where every leaf is a specific type, in this case translations by a set of languages:

type Language = "pl" | "en";

type Translation = { [ lang in Language ]: string };
type Translations = { [key: string]: Translation | Translations | undefined };

An example of this might be:

const translations = {
  hello: {
    pl: "Dzieńdobry",
    en: "Hello",
  },
  bool: {
    yes: {
      pl: "Tak",
      en: "Yes",
    },
    no: {
      pl: "Nie",
      en: "No",
    },
  },
};

I want a type for the paths: "hello", "bool.yes" and "bool.no", but not to "bool" or "missing" or "bool.foo" or "hello.pl"`. Here's what I have so far:

For a single layered translations file:

type KeyToTranslation<T extends Translations, K extends string = string> = K extends keyof T 
  ? T[K] extends Translation
    ? K
    : never
  : never;

function printTranslationByKey<T extends Translations, K extends string>(
  t: T,
  k: KeyToTranslation<T, K>
) {
    console.log(t[k]);
}
printTranslationByKey(translations, "hello"); // Valid, Correct!
printTranslationByKey(translations, "hello.pl"); // Error, Correct!
printTranslationByKey(translations, "missing"); // Error, Correct!
printTranslationByKey(translations, "bool.yes"); // Error, Incorrect.

So we need some template strings and infers. Unfortunately I can't seem to get this working. It seems to forget that I've already asserted that T[TKey] extends Translations:

type DeepKeyToTranslation<T extends Translations, K extends string = string> =  K extends keyof T
    ? T[K] extends Translation
        ? K
        : never
    :
      // This is where we extend this further to cover the dot separated case:
      
      K extends `${infer TKey}.${infer Rest}`
        ? TKey extends keyof T
            ? T[TKey] extends undefined
                ? never
                : T[TKey] extends Translations
                    ? Rest extends DeepKeyToTranslation<T[TKey], Rest>
                        ? K
                        : never
                    : never
            : never
        : never;

function printTranslationByDeepKey<T extends Translations, K extends string>(
  t: T,
  k: DeepKeyToTranslation<T, K>
) {
    console.log(t[k]);
}
printTranslationByDeepKey(translations, "hello"); // Good!
printTranslationByDeepKey(translations, "hello.pl"); // Error
printTranslationByDeepKey(translations, "missing"); // Error

It fails because:

Type 'T[TKey]' does not satisfy the constraint 'Translations'.
  Type 'T[string]' is not assignable to type 'Translations'.
    Type 'Translation | Translations | undefined' is not assignable to type 'Translations'.
      Type 'undefined' is not assignable to type 'Translations'.()

I've found several other similar questions:

  • Typescript: deep keyof of a nested object
  • Generic function to get a nested object value

The second was even the base for my current implementation, but neither provide a similar function where you can stop recursion based on the type of the value at the path.


Solution 1:

Consider this solution:

type Language = "pl" | "en";


const translations = {
  hello: {
    pl: "Dzieńdobry",
    en: "Hello",
  },
  bool: {
    yes: {
      pl: "Tak",
      en: "Yes",
    },
    no: {
      pl: "Nie",
      en: "No",
    },
  },
} as const;

type Translations = typeof translations;

type KeysUnion<T, Cache extends string = ''> =
  T extends PropertyKey ? Cache : keyof T extends Language ? Cache : {
    [P in keyof T]:
    P extends string
    ? Cache extends ''
    ? KeysUnion<T[P], `${P}`>
    : Cache | KeysUnion<T[P], `${Cache}.${P}`>
    : never
  }[keyof T]

type Result = KeysUnion<Translations>

function printTranslationByDeepKey<T extends Translations, K extends KeysUnion<Translations>>(
  t: T,
  k: K
) {}
printTranslationByDeepKey(translations, "hello"); // Good!
printTranslationByDeepKey(translations, "hello.pl"); // Error
printTranslationByDeepKey(translations, "missing"); // Error

Playground

KeysUnion - recursively iterates through Translations type. If Cache extends "" - it means that it is first iteration and we need to call KeysUnion recursively with approperiate arguments but without using Cache memoization, since it makes no sense to have an empty string in a result. If Cache is not an empty string, apply Cache to result (unionize it with result) and call KeysUnion<T[P], ${Cache}.${P}> with new suffix.

T extends PropertyKey - if T is no more object - return Cache keyof T extends Language - if T is an object with pl/en keys - no need to apply them to result - return Cache.

You can find another one explanation of this utility type with comments here

You can find much more explanation in my blog

List of related answers: [ first, second, third, fourth ]

Solution 2:

I'm not entirely sure the | undefined part of your Translations type is needed (might be wrong here), and I suspect that's what causing most of the trouble.

I slightly reworked your example and got to a point that seems to be working, have a look at it and see if it covers your use case!

type Language = "pl" | "en";

type Translation = { [lang in Language]: string };
type Translations = { [key: string]: Translation | Translations };

type DeepKeyToTranslation<T extends Translations, K extends string = string> = K extends keyof T
    ? T[K] extends Translation
        ? K
        : never
    : K extends `${infer TKey}.${infer Rest}`
        ? T[TKey] extends Translations
            ? DeepKeyToTranslation<T[TKey], Rest> extends never
                ? never
                : K
            : never
        : never

declare function printTranslationByDeepKey<T extends Translations, K extends string>(
    t: T,
    k: DeepKeyToTranslation<T, K>
): K

const translations = {
    hello: {
        pl: "Dzieńdobry",
        en: "Hello",
    },
    bool: {
        yes: {
            pl: "Tak",
            en: "Yes",
        },
        no: {
            pl: "Nie",
            en: "No",
        },
    },
    nested: {
        deep: {
            ok: {
                pl: "Dzieńdobry",
                en: "Hello",
            }
        }
    }
};


printTranslationByDeepKey(translations, "hello") // ok
printTranslationByDeepKey(translations, "bool.yes") // ok
printTranslationByDeepKey(translations, "hello.pl") // err
printTranslationByDeepKey(translations, "missing") // err
printTranslationByDeepKey(translations, "nested.deep.ok") // ok

Playground Link