Is there a possibility to type check existing keys in react-i18next dictionaries? So that TS will warn you during compile time if key doesn't exist.
Example.
Suppose, we have this dictionary:
{
"footer": {
"copyright": "Some copyrights"
},
"header": {
"logo": "Logo",
"link": "Link",
},
}
If I provide non-existent key, TS should blow up:
const { t } = useTranslation();
<span> { t('footer.copyright') } </span> // this is OK, because footer.copyright exists
<span> { t('footer.logo') } </span> // TS BOOM!! there is no footer.logo in dictionary
What is the proper name of this technique? I'm very sure I'm not the only one who is asking for this behavior.
Is it implemented in react-i18next
out of the box?
Are there API in react-i18next
to extend the library somehow to enable it? I want to avoid creating wrapper functions.
finally supports typed string-key lookups and interpolation via template literal types.
We now can use a dotted string argument to access dictionary keys / the object path deeply:
t("footer"); // ✅ { copyright: "Some copyrights"; }
t("footer.copyright"); // ✅ "Some copyrights"
t("footer.logo"); // ❌ should trigger compile error
Let's look 1.) at a suitable return type for a translate function t
2.) how we can emit a compile error on non-matching key arguments and provide IntelliSense 3.) at an example of string interpolation.
// returns property value from object O given property path T, otherwise never
type GetDictValue<T extends string, O> =
T extends `${infer A}.${infer B}` ?
A extends keyof O ? GetDictValue<B, O[A]> : never
: T extends keyof O ? O[T] : never
function t<P extends string>(p: P): GetDictValue<P, typeof dict> { /* impl */ }
It might be sufficient to just trigger compile errors on wrong keys:
// returns the same string literal T, if props match, else never
type CheckDictString<T extends string, O> =
T extends `${infer A}.${infer B}` ?
A extends keyof O ? `${A}.${Extract<CheckDictString<B, O[A]>, string>}` :never
: T extends keyof O ? T : never
function t<P extends string>(p: CheckDictString<P, typeof dict>)
: GetDictValue<P, typeof dict> { /* impl */ }
Read on, if you also want IntelliSense. Following type will query all possible key path permutations of the dictionary, provide auto complete and assist with error hints for non-matching keys:
// get all possible key paths
type DeepKeys<T> = T extends object ? {
[K in keyof T]-?: `${K & string}` | Concat<K & string, DeepKeys<T[K]>>
}[keyof T] : ""
// or: only get leaf and no intermediate key path
type DeepLeafKeys<T> = T extends object ?
{ [K in keyof T]-?: Concat<K & string, DeepKeys<T[K]>> }[keyof T] : "";
type Concat<K extends string, P extends string> =
`${K}${"" extends P ? "" : "."}${P}`
function t<P extends DeepKeys<typeof dict>>(p: P) : GetDictValue<P, typeof dict>
{ /* impl */ }
type T1 = DeepKeys<typeof dict>
// "footer" | "header" | "footer.copyright" | "header.logo" | "header.link"
type T2 = DeepLeafKeys<typeof dict>
// "footer.copyright" | "header.logo" | "header.link"
See Typescript: deep keyof of a nested object for more details.
Due to combinatory complexity and depending on dictionary object shape, you might hit compiler recursion depth limits. A more lightweight alternative: provide IntelliSense for the next key path incrementally based on current input:
// T is the dictionary, S ist the next string part of the object property path
// If S does not match dict shape, return its next expected properties
type DeepKeys<T, S extends string> =
T extends object
? S extends `${infer I1}.${infer I2}`
? I1 extends keyof T
? `${I1}.${DeepKeys<T[I1], I2>}`
: keyof T & string
: S extends keyof T
? `${S}`
: keyof T & string
: ""
function t<S extends string>(p: DeepKeys<typeof dict, S>)
: GetDictValue<S, typeof dict> { /* impl */ }
t("f"); // error, suggests "footer"
t("footer"); // OK
t("footer."); // error, suggests "footer.copyright"
t("footer.copyright"); // OK
t("header.") // error, suggests "header.logo" | "header.link"
Here is an example making use of string interpolation.
// retrieves all variable placeholder names as tuple
type Keys<S extends string> = S extends '' ? [] :
S extends `${infer _}{{${infer B}}}${infer C}` ? [B, ...Keys<C>] : never
// substitutes placeholder variables with input values
type Interpolate<S extends string, I extends Record<Keys<S>[number], string>> =
S extends '' ? '' :
S extends `${infer A}{{${infer B}}}${infer C}` ?
`${A}${I[Extract<B, keyof I>]}${Interpolate<C, I>}`
: never
Example:
type Dict = { "key": "yeah, {{what}} is {{how}}" }
type KeysDict = Keys<Dict["key"]> // type KeysDict = ["what", "how"]
type I1 = Interpolate<Dict["key"], { what: 'i18next', how: 'great' }>;
// type I1 = "yeah, i18next is great"
function t<
K extends keyof Dict,
I extends Record<Keys<Dict[K]>[number], string>
>(k: K, args: I): Interpolate<Dict[K], I> { /* impl */ }
const ret = t('key', { what: 'i18next', how: 'great' } as const);
// const ret: "yeah, i18next is great"
Note: All snippets can be used in combination with react-i18next
or independently.
(PRE TS 4.1) There are two reasons why strong typed keys are not possible in react-i18next
:
1.) TypeScript has no way to evaluate dynamic or computed string expressions like 'footer.copyright'
, so that footer
and copyright
could be identified as key parts in the translations object hierarchy.
2.) useTranslation
does not enforce type constraints to your defined dictionary/translations. Instead function t
contains generic type parameters defaulting to string
, when not manually specified.
Here is an alternative solution that makes use of Rest parameters/tuples.
Typedt
function:
type Dictionary = string | DictionaryObject;
type DictionaryObject = { [K: string]: Dictionary };
interface TypedTFunction<D extends Dictionary> {
<K extends keyof D>(args: K): D[K];
<K extends keyof D, K1 extends keyof D[K]>(...args: [K, K1]): D[K][K1];
<K extends keyof D, K1 extends keyof D[K], K2 extends keyof D[K][K1]>(
...args: [K, K1, K2]
): D[K][K1][K2];
// ... up to a reasonable key parameters length of your choice ...
}
Typed useTranslation
Hook:
import { useTranslation } from 'react-i18next';
type MyTranslations = {/* your concrete type*/}
// e.g. via const dict = {...}; export type MyTranslations = typeof dict
// import this hook in other modules instead of i18next useTranslation
export function useTypedTranslation(): { t: TypedTFunction<typeof dict> } {
const { t } = useTranslation();
// implementation goes here: join keys by dot (depends on your config)
// and delegate to lib t
return { t(...keys: string[]) { return t(keys.join(".")) } }
}
Import useTypedTranslation
in other modules:
import { useTypedTranslation } from "./useTypedTranslation"
const App = () => {
const { t } = useTypedTranslation()
return <div>{t("footer", "copyright")}</div>
}
Test it:
const res1 = t("footer"); // const res1: { "copyright": string;}
const res2 = t("footer", "copyright"); // const res2: string
const res3 = t("footer", "copyright", "lala"); // error, OK
const res4 = t("lala"); // error, OK
const res5 = t("footer", "lala"); // error, OK
You potentially could infer those types automatically instead of the multiple overload signatures (Playground). Be aware that these recursive types are not recommended for production by core developers till TS 4.1.