问题
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.
回答1:
TS 4.1
finally supports typed string-key lookups and interpolation via template literal types.
Typed key lookup
Use a dotted string argument to access dictionary keys / object path deeply:t("footer"); // ✅ { readonly copyright: "Some copyrights"; }
t("footer.copyright"); // ✅ "Some copyrights"
t("footer.logo"); // ❌ never
function t<P extends string>(p: P): GetDictValue<P, typeof dict> { /* impl */ }
// returns the property value from object O given property path T
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
Playground
Compile error on missing key
You can enhance the type further to trigger a compile error, if a key does not match:
// 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: P & CheckDictString<P, typeof dict>):
GetDictValue<P, typeof dict> { /* impl */ }
t("footer.logo");
//^-----------^ ---- ❌ compile-error
Playground
Interpolation
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"
Playground
Note: All snippets can be used in combination with react-i18next
or more independently.
Old answer
(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
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.
回答2:
Another way to achive this behavior is to generate the TranslationKey type and use it than in useT hook and custom Trans component.
- create translation.json file
{
"PAGE_TITLE": "Product Status",
"TABLES": {
"COUNTRY": "Country",
"NO_DATA_AVAILABLE": "No price data available"
}
}
- generate type TranslationKey with generateTranslationTypes.js
/**
* 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)
- useT hook similar to useTranslation that uses TranslationKey type
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
- T component similar to Trans component
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
- use useT and T with type checked ids
const MyComponent = () => {
const { t } = useT()
return (
<div>
{ t("PAGE_TITLE", {count: 1})}
<T id="TABLES.COUNTRY" options={{count: 1}} />
</div>
)
}
回答3:
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.
来源:https://stackoverflow.com/questions/58277973/how-to-type-check-i18n-dictionaries-with-typescript