A use case for TypeScript template literal types

I recently had a chance to use TypeScript's template literal types.

Motivation

I was working on adding i18n to the frontend using i18next/i18next.

You can define a dictionary file as a nested object, but when specifying a key you need to write it as common.badge.required. The problem when using TypeScript is that there is no type checking on the key string, so typos go unnoticed.

import { t } from 'i18next';

const translation = {
    common: {
        badge: {
            required: 'Required',
            optional: 'Optional'
        }
    }
}

export const resources = {
    ja: {
        translation: translation,
    },
} as const;

i18n.init({
    lng: 'ja',
    resources: resources,
})

// 'badge' is typo'd as 'bage', but you won't notice until runtime
console.log(t('common.bage.required'));

Defining types with template literal types

This problem can be solved by using template literal types to generate a literal type of all keys joined by . from the object.

The FlattenKeyOf utility type takes an object type as a type argument and uses Mapped Types to get a list of keys. It uses Conditional Types to check if the value for each key is an object. If it is, it adds the key followed by . to the front and recursively calls FlattenKeyOf on the nested object to build the remaining key string. If the value is not an object, it returns the key as a literal type.

The string & K part is written because K is treated as string | number | symbol, but template literal types only accept primitive types. Using an Intersection Type returns K if it is a string, and never otherwise, which avoids the type error.

// const translation = { link: { top: 'Top Page' }}
// type keys = FlattenKeyOf<typeof translation> = 'link.top'
// FlattenKeyOf<{link: { top: 'Top Page' }}> = {
//     ['link']: `link.top` <= `link.${FlattenKeyOf<{top: 'Top Page'}>`
// }['link']                               ^
//                                         |__ 'top' = FlattenKeyOf<{top: 'Top Page'}> = { ['top']: 'top' }['top']
type FlattenKeyOf<T extends Record<string, unknown>> = {
    [K in keyof T]: T[K] extends Record<string, unknown> ? `${string & K}.${FlattenKeyOf<T[K]>}` : string & K
}[keyof T]

// common.badge.required | common.badge.optional
type ResourceKeys = FlattenKeyOf<typeof messages>;

// The key string argument is limited to a union type of keys in the dictionary file,
// so a type error will occur for typos.
console.log(t<string, ResouceKeys>('common.bage.required'))