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!