Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to type check i18n dictionaries with TypeScript?

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.

like image 970
Green Avatar asked Oct 07 '19 22:10

Green


3 Answers

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.

like image 66
Aaron Frary Avatar answered Oct 19 '22 14:10

Aaron Frary


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.

like image 39
ford04 Avatar answered Oct 19 '22 14:10

ford04


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

like image 2
madflanderz Avatar answered Oct 19 '22 15:10

madflanderz