Static type checking for collections of string constants in TypeScript

… when TS’ string enums don’t quite get you there.

The ultimate goal

UPDATE: I was recently able to simplify the solution I describe in this article using const assertions. See my brief follow-up article here.

A lot of string constants are used in the UI component library I currently work on at RadarServices. While migrating it from Flow to TypeScript, I wondered whether the workflow couldn’t be improved upon. Which consisted of many runtime checks to ensure that only constants of a certain allowed set (and in many cases subsets) were in fact passed. As well as tests that ensure that those checks work.

I had never used TS’ string enums before but when I saw them, I figured that they would be the way to go. I mean, come on — static checks for whether you use an acceptable constant? Awesome, problem solved!

Well… not quite.

The following simple React-based example illustrates what I needed and how I eventually resolved the problem. Autocomplete and ensured consistency for BEM modifiers across a UI component library:

The following describes the journey how I arrived there and why TS’ own enums were not enough.

The naive approach

One of my first attempts was to create the enum and trying to split it up like this:

The following represents the least of what I needed to be able to do:

So, using the enums separately works but TS can’t infer a relationship between enums based on assignments like above, hence the errors.

The somewhat refined approach

I tried it the other way around — merging sub sets into one superset:

Which leads to a slight improvement:

I find the part at the end interesting because it demonstrates what is probably obvious to most (since you can’t have the same key twice in an object) — the type of Enum looks like this:

const Enum: {
[x: number]: string;
Bar: SubEnum2.Bar;
Moo: SubEnum2.Moo;
Foo: SubEnum1.Foo;
}

… so it deviates from the union type — which does recognize the Bar of both subsets.

Pros

  • Static type check allows assigning only enum values.
  • Values of subsets can be assigned to a superset variable.

Cons

  • TS still doesn’t understand that Bar is the same in the two subsets. (The key problem for what I needed)
  • Unlike in the first naive approach, typos can be problematic. If you have one in one of the values for Bar in one of the enums, you will not be warned about it.
  • One needs to declare overlapping values every time — annoying if there are a number of long strings used in a few different enums.
  • While using the same name (Enum) for type and variable is nice within a single module and consistent with how native enums work, it won’t work if you try to import both.

The flexible/creative/D.I.Y. approach (a.k.a. what works for me)

I would like to note that I don’t recommend actually naming/calling these enums, since they technically aren’t! I only did it here for consistency with the previous examples.

Now a bit of analysis for how I arrived at this. Obviously, when doing e.g. const val: SubEnumB = SubEnumA.FOO, we want TS to be able to recognize that this doesn’t work. Trying to restrict which key is allowed to be used is obviously a non-starter for an assignment. So TS instead needs to understand what the type of the value of SubEnumA.FOO is. If we were to initialize it in the base enum using just FOO: ‘foo’, that type would be string. Not useful for enforcing a limited range of specific string literals.

Which is when I realized that I needed to somehow dynamically generate a union of string literal types. The only way I could find to do this is using keyof. If one was to merely use FOO: ‘foo’ like just mentioned and use keyof on that, the generated type would not be string literal type union but simply string. But once we cast the string literals to string literal types, keyof can be used to generate a union of those.

Assigning the Enum values in our subsets ensures that the values and their types are consistent across the code base.

An added benefit of this solution is that it is quite resistant to typos:

const Enum = {
Foo: ‘foo’ as ‘foo’,
Bar: ‘barn’ as ‘bart’,
Moo: ‘moo’ as ‘moo’,
};

Since we have a single source of truth here (as opposed to the “somewhat refined approach” above), we only need to fix ’barn’ here. ’bart’ could theoretically even be left alone because TS only has knowledge of the string literal type and doesn’t care about whether it is actually the same as the value. It of course should be the case to make maintenance easy — but that is by convention. As long as the string literal types are all unique, TS will be able to do the type checks we expect.

Theoretically, you could even do the following:

const Enum = {
Foo: ‘foo’ as ‘0’,
Bar: ‘bar’ as ‘1’,
Moo: ‘moo’ as ‘2’,
};

But that would cause TS errors that are difficult to interpret, such as Type ‘“1”’ is not assignable to type ‘“0” | “2”’.

Something that would make more sense is the following alternative:

const Enum = {
Foo: ‘foo’ as ‘Foo’,
Bar: ‘bar’ as ‘Bar’,
Moo: ‘moo’ as ‘Moo’,
};

Using the key name as the literal type by convention makes it possible to for TS to generate errors like Type ‘“Moo”’ is not assignable to type ‘“Foo” | “Bar”’, so one can easily see the allowed keys. Plus, type checking would still work reliably even if a few keys contain the same values.

But while this sounds great, I see it as too much of a drawback that the type we define for e.g. Enum would not describe the type of the values it can contain but instead the keys that can be assigned to it. After all, when you work with TS, you have the expectation that the type hints you get in your IDE indicate the type of the value that a variable contains, not keys that are assignable to it. Which would lead to confusion for people who are unfamiliar with the pattern.

Using lodash’s pick and utility-types’s $Values, the creation of subsets can be simplified quite a bit:

const SubEnumA = pick(Enum, [
‘Foo’,
‘Bar’,
]);
type SubEnumA = $Values<typeof SubEnumA>;

Since lodash’s types generate the appropriate type for the subset, autocomplete still works correctly.

So, how does the “creative approach” stack up overall?

Pros

  • Similar static type checking as with TS’ string enums. (Except that e.g. const val: Enum = ‘foo’ would work — which doesn’t with enums.)
  • Values of subsets can be mixed (or will lead to errors) as one would expect.
  • Provides typo resistance.

Cons

  • More verbose than enums.
  • While using the same name (Enum) for type and variable is nice within a single module and consistent with how native enums work, it won’t work if you try to import both.

Neutral

  • Values are matched, not keys. So values should be unique! (Just imagine you were to assign ’foo’ to both Foo and Bar. Then create a subset that only contains Foo, not Bar. Since the values are matched, TS will assume that you can assign the value of Bar to this subset. And… technically, it of course works. But it would make for confusing, error-prone code.)
  • Values and their types should be somewhat related to the names of the keys, otherwise TS errors such as this will be difficult to interpret: Type ‘“moo”’ is not assignable to type ‘“foo” | “bar”’.
  • As demonstrated in the example at the beginning of this section, one should never cast to a different string union type.

If you know a way that is similarly robust yet flexible but more elegant, please do let me know. 😊

And just to be safe, once more: Be sure to also check out my follow-up, since that simplifies things quite a bit.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store