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