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 infer
s. 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