27

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.

Green
  • 28,742
  • 61
  • 158
  • 247

6 Answers6

42

TS 4.1

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.

1. Key lookup: return type

// 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

2. Key lookup: IntelliSense and compile errors

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

3. 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 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.

Typed 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 ...
}

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.

ford04
  • 66,267
  • 20
  • 199
  • 171
  • 1
    I was talking about i18n interpolation like `i18next.t('key', { what: 'i18next', how: 'great' });`. check here https://www.i18next.com/translation-function/interpolation – Varun Sukheja Sep 15 '20 at 03:19
  • @VarunSukheja updated answer with interpolation – ford04 Dec 17 '20 at 16:19
  • Is it possible to have intellisense with this? – Yassine Bridi Feb 11 '21 at 16:18
  • 1
    @YassineBridi good suggestion. Updated answer with a section on IntelliSense. – ford04 Feb 11 '21 at 18:05
  • 1
    how about loading the json from a file? – Lucas Steffen Jul 01 '21 at 16:33
  • understand nothing (now i realized that i dont know nothing about TS ) congrats on the answer! – Rafo Oct 17 '22 at 00:06
  • @Rafo This is probably one of the more advanced concepts, hence no worries (-: . Most of the useful stuff in TS only needs basic knowledge. – ford04 Oct 17 '22 at 14:48
  • Use `const value = t(keyName as (keyof Dict));` to get value of variable key. – zwcloud Nov 14 '22 at 14:16
  • Since **typescript 4.7.4** (maybe 4.7) the recursive solution of **2. Key lookup** doesn't work correctly with IntelliSense anymore, the best solution is, therefore, the previous one, the one with `DeepLeafKeys`. This can be seen in the playground, switching between v4.6.4 and v4.7.4 – Jerónimo Ekerdt May 31 '23 at 14:04
23

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.

Aaron Frary
  • 906
  • 1
  • 13
  • 24
  • 3
    For anyone on `react-i18next` versions higher than 11.11.0 and you're following https://react.i18next.com/latest/typescript, and you're getting the issue of `Module '"react-i18next"' has no exported member 'initReactI18next'.` or `Module '"react-i18next"' has no exported member 'useTranslations'.` (any import name really), all you need to do to fix this is rename your type file from `react-i18next.d.ts` to `i18n-resources.d.ts` as this answer shows. The file name the documentation shows appears to break merging, renaming will fix and keep your typing. – Cow Jan 07 '22 at 00:46
2

Another way to achive this behavior is to generate the TranslationKey type and use it than in useT hook and custom Trans component.

  1. create translation.json file
{
  "PAGE_TITLE": "Product Status",
  "TABLES": {
    "COUNTRY": "Country",
    "NO_DATA_AVAILABLE": "No price data available"
  }
}
  1. 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)

  1. 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

  1. 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

  1. 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>
    )
}

madflanderz
  • 1,159
  • 2
  • 7
  • 7
1

Great answer @ford04, but has a little trouble on Keys and Interpolate type, if you use in this way and don't have a variable in the end of string, the interpolate will not identify it. To resolve this problem, can be did this way:

export type Keys<S extends string> =
  S extends `${string}{{${infer B}}}${infer C}`
    ? C extends `${string}{{${string}}}${string}`
      ? [B, ...Keys<C>]
      : [B]
    : never;
type Interpolate<
  S extends string,
  I extends Record<Keys<S>[number], string>,
> = S extends ''
  ? ''
  : S extends `${infer A}{{${infer B}}}${infer C}`
  ? C extends `${string}{{${string}}}${string}`
    ? `${A}${I[Extract<B, keyof I>]}${Interpolate<C, I>}`
    : `${A}${I[Extract<B, keyof I>]}`
  : never;

Follow the example: Playground

0

I wrote a cli that supports the generation of dts type definition files from multiple json configurations. You can try it. At present, the advanced types of ts 4 have not yet fully supported the features of i18next, so I chose code generation.

https://www.npmjs.com/package/@liuli-util/i18next-dts-gen

rxliuli
  • 165
  • 1
  • 11
0

The official documentation explains how to achieve type safety: https://www.i18next.com/overview/typescript

E.g.

// import the original type declarations
import "i18next";
// import all namespaces (for the default language, only)
import ns1 from "locales/en/ns1.json";
import ns2 from "locales/en/ns2.json";

declare module "i18next" {
  // Extend CustomTypeOptions
  interface CustomTypeOptions {
    // custom namespace type, if you changed it
    defaultNS: "ns1";
    // custom resources type
    resources: {
      ns1: typeof ns1;
      ns2: typeof ns2;
    };
    // other
  }
}

matanube
  • 407
  • 3
  • 11