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.
React-i18next now has built-in support for this. I couldn't find official documentation, but there are helpful comments in the source code.
Assuming your translations are in public/locales/[locale]/translation.json
and your primary language is English:
// src/i18n-resources.d.ts
import 'react-i18next'
declare module 'react-i18next' {
export interface Resources {
translation: typeof import('../public/locales/en/translation.json')
}
}
If you're using multiple translation files you'll need to add them all to the Resources interface, keyed by namespace.
Make sure to set "resolveJsonModule": true
in your tsconfig.json
if you're importing the translations from a json file.
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 */ }
Playground
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 */ }
Playground
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"
Playground
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
// fix issue allowed last dot
? T[I1] extends object
? `${I1}.${DeepKeys<T[I1], I2>}`
: keyof T & string
: 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 */ }
// IntelliSense suggestions and compile errors!
// Press Ctrl+Space just outside the string, inside parentheses
t("f"); // error, suggests "footer" | "header"
t("footer"); // OK
t("footer."); // error, suggests "footer.copyright"
t("footer.copyright"); // OK
t("header.") // error, suggests "header.logo" | "header.link"
t("footer.copyright."); // error, suggests "footer.copyright"
Playground
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
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"
Playground
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.
t
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 ...
}
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(".")) } }
}
useTypedTranslation
in other modules:import { useTypedTranslation } from "./useTypedTranslation"
const App = () => {
const { t } = useTypedTranslation()
return <div>{t("footer", "copyright")}</div>
}
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
Playground
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.
Another way to achive this behavior is to generate the TranslationKey type and use it than in useT hook and custom Trans component.
{
"PAGE_TITLE": "Product Status",
"TABLES": {
"COUNTRY": "Country",
"NO_DATA_AVAILABLE": "No price data available"
}
}
/**
* This script generates the TranslationKey.ts types that are used from
* useT and T components
*
* to generate type run this command
*
* ```
* node src/i18n/generateTranslationTypes.js
* ```
*
* or
* ```
* npm run generate-translation-types
* ```
*/
/* eslint-disable @typescript-eslint/no-var-requires */
const translation = require("./translation.json")
const fs = require("fs")
// console.log("translation", translation)
function extractKeys(obj, keyPrefix = "", separator = ".") {
const combinedKeys = []
const keys = Object.keys(obj)
keys.forEach(key => {
if (typeof obj[key] === "string") {
if (key.includes("_plural")) {
return
}
combinedKeys.push(keyPrefix + key)
} else {
combinedKeys.push(...extractKeys(obj[key], keyPrefix + key + separator))
}
})
return combinedKeys
}
function saveTypes(types) {
const content = `// generated file by src/i18n/generateTranslationTypes.js
type TranslationKey =
${types.map(type => ` | "${type}"`).join("\n")}
`
fs.writeFile(__dirname + "/TranslationKey.ts", content, "utf8", function(
err
) {
if (err) {
// eslint-disable-next-line no-console
console.log("An error occurred while writing to File.")
// eslint-disable-next-line no-console
return console.log(err)
}
// eslint-disable-next-line no-console
console.log("file has been saved.")
})
}
const types = extractKeys(translation)
// eslint-disable-next-line no-console
console.log("types: ", types)
saveTypes(types)
import { useTranslation } from "react-i18next"
import { TOptions, StringMap } from "i18next"
function useT<TInterpolationMap extends object = StringMap>() {
const { t } = useTranslation()
return {
t(key: TranslationKey, options?: TOptions<TInterpolationMap> | string) {
return t(key, options)
},
}
}
export default useT
import React, { Fragment } from "react"
import useT from "./useT"
import { TOptions, StringMap } from "i18next"
export interface Props<TInterpolationMap extends object = StringMap> {
id: TranslationKey
options?: TOptions<TInterpolationMap> | string
tag?: keyof JSX.IntrinsicElements | typeof Fragment
}
export function T<TInterpolationMap extends object = StringMap>({
id,
options,
tag = Fragment,
}: Props<TInterpolationMap>) {
const { t } = useT()
const Wrapper = tag as "div"
return <Wrapper>{t(id, options)}</Wrapper>
}
export default T
const MyComponent = () => {
const { t } = useT()
return (
<div>
{ t("PAGE_TITLE", {count: 1})}
<T id="TABLES.COUNTRY" options={{count: 1}} />
</div>
)
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With