loading...

How to eliminate "magic strings" with a fancy Typescript trick

dgreene1 profile image Dan Greene ・3 min read

One of the first things you learn as a professional developer is that magic strings are bad. Why? 🤔 Because typos stink and can really ruin an application in a way that's hard to track down.

For instance, my team and I were use Storybook to display all of the UI widgets that we have available in our library. And there were a couple of times where we misspelled something only to find that one of the widgets was missing from the final output.

The Ideal Goal

Ideally a developer wouldn't have to remember the full path or the agreed upon folder names for storybook. If we rely on memory, they're going to type things wrong.

The Solution?

We'll create a function that lets developers "crawl" the paths to create a concatenated path.

For instance, if we wanted the folder structure in the image below, I'll show you how we would initialize the title.
Alt Text

That would mean inside the dateAndTimePicker.stories.tsx file, I would initialize the title like this:

const title = makeTitle('Widgets')('Temporal')('Date Range Picker').finalize();

The coolest part of this approach is that you can't spell "Widgets" wrong... Typescript won't let you. And you also get Intellisense to help you remember what options are allowed.

Alt Text

Let's show how to make this possible.

Step 1: Store the hierarchy in a series of nested objects

This first step is basically the same as how you would eliminate "magic strings" in any project-- by creating constants. But the cool part here is that Typescript naturally lets you store a hierarchy of constants as a series of nested objects. You'll see the power that gives us later when we start to use keyof to create smarter types.

const headingsMapObj = {
    Widgets: {
        Temporal: {
            'Date Range Picker': 'Date Range Picker',
        },
        Input: 'Input',
        Checkbox: {
            'Single Checkbox': 'Single Checkbox',
            'Checkbox Group': 'Checkbox Group'
        }
    },
    'An Introduction': {
        Welcome: 'Welcome',
    },
    Patterns: {
        Spacing: 'Spacing',
        Flow: 'Flow'
    },
} as const;

Step 2: Turn this into a type

type HeadingsMap = typeof headingsMapObj;

Step 3: Create the path builder

const makeTitle = <K1 extends keyof HeadingsMap>(level1: K1) => {
    const paths: string[] = [];
    function pushKeyIfStringOrThrow(input: string | number | symbol){
        if(typeof input === 'string'){
            paths.push(input);
        } else {
            throw new TypeError(`Unsupported type: ${typeof input}`)
        }
    }

    const finalize = () => {
        return paths.join(' / ');
    };

    pushKeyIfStringOrThrow(level1)

    const builderFnLevel2 = <K2 extends keyof HeadingsMap[K1]>(level2: K2) => {
        pushKeyIfStringOrThrow(level2)
        const builderFnLevel3 = <K3 extends keyof HeadingsMap[K1][K2]>(level3: K3) => {
            pushKeyIfStringOrThrow(level3)
            const builderFnLevel4 = <K4 extends keyof HeadingsMap[K1][K2][K3]>(level3: K4) => {
                pushKeyIfStringOrThrow(level3)
                return {
                    finalize
                };
            };
            builderFnLevel4.finalize = finalize;
            return builderFnLevel4;
        };
        builderFnLevel3.finalize = finalize;
        return builderFnLevel3;
    };
    builderFnLevel2.finalize = finalize;
    return builderFnLevel2;
};

And that's it! :)

Okay, cool... but how does that work?

It would probably take a while to explain how and why that works. And I'll be honest, it's taken me a long time working with Typescript to create something this wild. And if you're interested in a detailed breakdown of how the above code works, reach out in the comments and I'll create a follow up post.

But the basic idea is that the keyof type operator creates a more strict type that is more narrow than a string.

So in the case of a type like:

const exampleObj = {
    'hello': 'Bill',
    'goodbye': 'Ted'
} as const;

We can create a type that only allows 'hello' | 'goodbye' by writing:

type exampleKeys = keyof typeof exampleObj;

Here's the next bit of magic. Let's say we wanted to get a type that was only 'Bill' | 'Ted'.

All we'd have to do is write:

type Example = typeof exampleObj;
type ExampleValues = Example[keyof Example];

Note: if ExampleValues is still string when you hover over it, you might have forgotten to add as const to the end of the exampleObj instantiation. Another dev.to user has a great explanation of what makes as const work.

Wrapping Up

Thanks for coming along in this brief journey on why Typescript is so fun and how it can solve unique code problems that no other language can solve. :)

Posted on by:

dgreene1 profile

Dan Greene

@dgreene1

I love to help teams grow and learn about how to write testable, efficient code that delights users.

Discussion

pic
Editor guide