Are you still enumerating your types manually?!
Ján Jakub Naništa
Posted on June 25, 2020
You know what? Let's follow the literary tradition of Greek epic poems, let's skip all the introduction and jump straight into the middle of the battle, in medias res!
Have you ever seen code that looked something like this?
// You have some sort of a literal type
type ButtonType = 'primary' | 'secondary' | 'link';
// And then somewhere else you want to list all its values,
// maybe in a dropdown in your storybook or a WYSIWYG editor
const buttonTypes: ButtonType[] = ['primary', 'secondary', 'link'];
Or like this?
// You have some sort of an interface, API request payload for example
interface SomePayload {
name: string;
quantity: number;
}
// Then somewhere in your codebase you want to do something
// with the properties of this interface
const payloadKeys = ['name', 'quantity'];
If you're aware of the problems with the code above, feel free to skip the next couple of paragraphs. If not, let's look at the issues with the aforementioned code.
Union type values
First, let's disect the ButtonType
situation. In our code we defined a buttonTypes
array that holds the possible values of our ButtonType
union:
const buttonTypes: ButtonType[] = ['primary', 'secondary', 'link'];
So far so good. Let's now try deleting one of the buttonTypes
array elements:
// ButtonType 'link' is no longer in the array
const buttonTypes: ButtonType[] = ['primary', 'secondary'];
If you now run your code, TypeScript will not complain about the missing link
value. Why would it - buttonTypes
is still an array of ButtonType
values, nowhere did we say it is an array of all the ButtonType
values. And currently, there is no easy or pretty way of doing that. (if you are looking for an ugly hack I might have a gist for you).
We get the same problem when the powers above, represented by for example a product owner, decide we need a new ButtonType
, let's call it error
:
// Our new error type ↴
type ButtonType = 'primary' | 'secondary' | 'error' | 'link';
Again, if we don't change our original buttonTypes
array, the code will still compile.
// This compiles fine even though we now have a new ButtonType
const buttonTypes: ButtonType[] = ['primary', 'secondary', 'link'];
Let's see whether there are any nice workarounds out there. If, for example, your tech lead prefers enums
over unions, you might be tempted to use the fact that enum
is just a fancy const
:
enum ButtonType {
PRIMARY = 'primary',
SECONDARY = 'secondary',
LINK = 'link'
}
const buttonTypes: ButtonType[] = Object.values(ButtonType) as ButtonType[];
If you now console.log
the buttonTypes
you might be surprised:
console.log(buttonTypes);
// The output does not look like what we'd expect!
['primary', 'PRIMARY', 'secondary', 'SECONDARY', 'link', 'LINK']
Y U DO THIS TYPESCRIPT?!
Well, in fact, there is a good reason - TypeScript wants you to be able to do something like this:
const enumKey = ButtonType[ButtonType.PRIMARY]
So it creates an object that has both the forward mapping (PRIMARY > primary
) as well as the reverse one (primary -> PRIMARY
). So we are back to square one, we still need to enumerate our ButtonType
manually, with the very same drawbacks as before.
Interface properties
If we now look at the second example with SomePayload
, we see a similar situation. If we omit a value from our payloadKeys
or add an extra key to SomePayload
, our code will still compile just fine.
Now if you are as paranoid and as lazy when it comes to typing as I am, you'll probably spend an hour or two looking for a good solution that would be less error-prone and, well, prettier. And if you are as uncompromising as me, you'll set off to create your own solution if your search yields no results.
ts-reflection
to the rescue!
Without further ado, let me introduce you to ts-reflection
, a TypeScript transformer that addresses both of the problems above (and much more).
With the help of ts-reflection
we can turn our ButtonType
code into something like:
import { valuesOf } from 'ts-reflection';
// OMG SO MUCH BETTER
const buttonTypes: ButtonType[] = valuesOf<ButtonType>();
And our SomePayload
example becomes:
import { propertiesOf } from 'ts-reflection';
// FREEDOM!!!
const payloadKeys = propertiesOf<SomePayload>();
If you can't wait to try it yourself feel free to drop by the project Github or install the package from NPM. If though you want to see some advanced features, keep reading!
Going deeper
Seeing the propertiesOf
function above you might have been thinking: Ohhhh I have seen this before, it's the good ol' ts-transformer-keys
!!! Strictly speaking, ts-reflection
is a superset of ts-transformer-keys
: not only it gives you access to the valuesOf
utility, it also allows you to do some EVIL PROPERTY MAGIC!
Okay, that might have been an exaggeration, it's just that I just love some drama with my coding.
propertiesOf
will by default return all the public
properties of a type. However, it allows you to customize its output so that you can include or exclude public
, protected
, private
, readonly
and optional
properties:
// This will list all the readonly properties of MyInterface
const readonlyProperties = propertiesOf<MyInterface>({ readonly: true });
// This will list all the optional properties of MyInterface
const optionalProperties = propertiesOf<MyInterface>({ optional: true });
// This will list all the required properties of MyInterface
const requiredProperties = propertiesOf<MyInterface>({ optional: false });
// But feel free to ask for private readonly OR public optional properties
const verySpecificProperties = propertiesOf<MyInterface>(
{ private: true, readonly: true }
{ public: true, optional: true }
);
// Or maybe a combination of required non-public properties
// and protected optional ones? I mean why not
const evenMoreSpecificProperties = propertiesOf<MyInterface>(
{ public: false, optional: false }
{ protected: true, optional: true }
);
It also allows you to get keys of an enum
or any other type:
const buttonTypeKeys = propertiesOf<typeof MyEnum>();
const stringProperties = propertiesOf<string>();
const promiseProperties = propertiesOf<Promise<unknown>>();
Thank you for reading all the way down here! If you have any comments or questions don't hesitate to use the comments section below, if you have any ideas or feature requests please file an issue on the project Github, your input is verimuch appreciated!
Posted on June 25, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.