Externalizing Strings in React - Part 2

Part 2 of our Externalizing Strings in React series! Here's how we can leverage TypeScript.

Published on
3/23/22

Continuing from Externalizing Strings in React - Part 1, we’ll look at leveraging TypeScript to provide static analysis, validation, and autocomplete.

First, we must write a node script. This will read a `YAML` file and generate types. For example, if we have the following YAML:

key1: value1
key2: value2
key3:
key3_1: value3_1
key3_2: value3_2

Then it should produce the following type:

type StringKey = 'key1' | 'key2' | 'key3.key3_1' | 'key3.key3_2'

Note that, for nested values, we’re generating the key using all of the keys of its parent (until we reach the root), separated by a `.`. This is a general convention followed in the JavaScript ecosystem and supported by libraries such as lodash.

Next, we can utilize this type within the `StringsContext` file and take advantage of TypeScript.

The following script should read the YAML file and generate the types:

import fs from "fs";
import path from "path";
import yaml from "yaml";

/**
* Loops over object recursively and generate paths to all the values
* { foo: "bar", foo2: { key1: "value1", key2: "value2" }, foo3: [1, 2, 3] }
* will give the result:
*
* ["foo", "foo2.key1", "foo2.key2", "foo3.0", "foo3.1", "foo3.2"]
*/
function createKeys(obj, initialPath = "") {
 return Object.entries(obj).flatMap(([key, value]) => {
   const objPath = initialPath ? `${initialPath}.${key}` : key;

   if (typeof value === "object" && value !== null) {
     return createKeys(value, objPath);
   }

   return objPath;
 });
}

/**
* Reads input YAML file and writes the types to the output file
*/
async function generateStringTypes(input, output) {
 const data = await fs.promises.readFile(input, "utf8");
 const jsonData = yaml.parse(data);
 const keys = createKeys(jsonData);

 const typesData = `export type StringKeys =\n  | "${keys.join('"\n  | "')}";`;

 await fs.promises.writeFile(output, typesData, "utf8");
}

const input = path.resolve(process.cwd(), "src/strings.yaml");
const output = path.resolve(process.cwd(), "src/strings.types.ts");

generateStringTypes(input, output);

You can put this script in `scripts/generate-types.mjs` and run `node scripts/generate-types.mjs`. Furthermore, you should see `src/strings.types.ts` being written with the following content:

export type StringKeys =
 | "homePageTitle"
 | "aboutPageTitle"
 | "homePageContent.para1"
 | "homePageContent.para2"
 | "homePageContent.para3"
 | "aboutPageContent.para1"
 | "aboutPageContent.para2"
 | "aboutPageContent.para3";

The script, in its current form, doesn’t handle all of the use cases/edge cases. You can enhance it, when required, and customize it according to your use case.

Now we can update the `StringsContext.tsx` to utilize the generated type `StringKeys`.

import React, { createContext } from "react";
import has from "lodash.has";
import get from "lodash.get";
import mustache from "mustache";

+ import type { StringKeys } from "./strings.types";

+ export type StringsMap = Record<StringKeys, string>;

- const StringsContext = createContext({} as any);
+ const StringsContext = createContext<StringsMap>({} as any);

export interface StringsContextProviderProps {
- data: Record<string, any>;
+ data: StringsMap;
}

export function StringsContextProvider(
 props: React.PropsWithChildren<StringsContextProviderProps>
) {
 return (
<StringsContext.Provider value={props.data}>
     {props.children}
</StringsContext.Provider>
 );
}

- export function useStringsContext(): Record<string, any> {
+ export function useStringsContext(): StringsMap {
 return React.useContext(StringsContext);
}

export interface UseLocaleStringsReturn {
- getString(key: string, variables?: any): string;
+ getString(key: StringKeys, variables?: any): string;
}

export function useLocaleStrings() {
 const strings = useStringsContext();

 return {
-   getString(key: string, variables: any = {}): string {
+   getString(key: StringKeys, variables: any = {}): string {
     if (has(strings, key)) {
       const str = get(strings, key);

       return mustache.render(str, variables);
     }

     throw new Error(`Strings data does not have a definition for: "${key}"`);
   },
 };
}

export interface LocaleStringProps extends React.HTMLAttributes<any> {
- strKey: string;
+ strKey: StringKeys;
 as?: keyof JSX.IntrinsicElements;
 variables?: any;
}

export function LocaleString(props: LocaleStringProps): React.ReactElement {
 const { strKey, as, variables, ...rest } = props;
 const { getString } = useLocaleStrings();
 const Component = as || "span";

 return <Component {...rest}>{getString(strKey, variables)}</Component>;
}

After this change, you should be able to utilize autocomplete and validation for presence strings using TypeScript.

Externalizing Strings, Pt. 2 - TypeScript
Externalizing Strings, Pt. 2 - TypeScript

Moreover, you can integrate the string generation into your build system. This will automate the generation of types whenever there is a change in the `strings.yaml` file. I've done it here using a vitejs plugin.

Conclusion

I hope you find this useful and will use it as a starting point for your own implementation. For those who missed Part 1, again, you can find it here: Externalizing Strings In React – Part 1.

Happy coding!

The Modern Software Delivery Platform™

Loved by Developers, Trusted by Businesses
Get Started

Need more info? Contact Sales