Static type checking for collections of string constants in TypeScript

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?ProsSimilar 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.

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

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

????.

. More details

Leave a Reply