Typechecking builder functions in Typescript
I've been working on toasting a lot of our tech debt at Repositive
recently. We use an event driven microservice architecture which has various benefits, but some
drawbacks concerning what data is sent where due in part to the liberal use of any
in our
Typescript codebases. During my refactoring rampage, I encountered some places where event objects
were missing fields or otherwise weren't being generated properly. To this end, I set out to create
a type-checked solution to this problem.
What events look like to us
Firstly, an event as seen on a Repositive-branded wire looks like this (prettified):
There's an ID field, a data
payload and a context
which holds (amongst other things IRL) and
event creation time.
Inside data
, there are three fields common to all events which denote which type of event it
is:
event_namespace
- the namespace (domain) from which this event was emitted (accounts
,products
, etc).event_type
- the type of this event, used to define what the payload should contain when consuming the event.type
- legacy field that contains both the above pieces of information.
The rest of the fields inside data
are freeform and can be anything, including nested objects, as
long as the object keys type
, event_type
and event_namespace
are not used.
Typescript implementation
In Typescript, we define some types to use when handling events like the above. There's one that
holds the three common event type fields EventData
:
This forms the core of an event's payload. Next, there's a wrapping type called Event
which is
what the complete event object should look like:
And finally let's define the event given in the JSON example above:
Note that I'm using string
a lot here. You should probably create type aliases like
type Uuid = string;
. It might not aid with format checking, but it will at least make clear to
other programmers what the intent of that field is.
A better idea might be to use io-ts which would let you do awesome things like validate your payloads at runtime using the type system!
Anyway, now that those types are defined, events can be created that match the correct type signature:
;
;
This isn't too bad. Fields in data
can't be missed and, critically, the event metadata (type
,
event_namespace
and event_type
) fields can't be typoed! Thanks Typescript!
First attempt: lazy is dangerous
The above is alright I guess. At least the final event object is checked at compile time before
serializing and sending/storing it. The problem is it's pretty verbose. Wouldn't it be nicer to have
a function that, given a data
payload just makes us an event? This is what I came up with to
solve this problem the first time:
;
Neat. Now the programmer doesn't have to care about the particular shape of the object, just some specific fields. Usage looks like this:
;
This is obviously a lot cleaner. The programmer doesn't have to worry about the joined type
field
matching event_namespace
and event_type
anymore, and the UUID and timestamp are automatically
inserted in the right places. The event's shape will also always be correct. But there's a
problem...
Typescript doesn't type check this properly! At least it didn't at the time of writing. For example,
adding an explicit type still doesn't catch the incorrect spelling of some_namespace
in the
example below:
;
This is more ergonomic, but is a step backward in the reliability of the system. Mistyped fields
and events with missing keys were encountered in production when using the createEvent
defined
above. This is pretty terrible. We should be pushing the programmer into the
pit of success!
Into the pit - safely
What createEvent
needs is some actual, smart type checking. Issues arose when we decided to make
createEvent
construct the returned object from a few different fields in its arguments.
Typechecking multiple arguments that get munged into a single object is pretty difficult (at least
in Typescript) but can be done as you'll see next:
;
;
Usage looks like this:
;
Nearly identical to before, save for adding <BlogEvent>
to the call signature. Our ergonomics are
preserved, but now we get proper type checking! Any errors in any arguments will fail to compile.
For example:
// Fails: typo in `SomeEventType`
;
// Fails: missing `some_other_field`
;
This code couples the power of generics with Typescripts weird (but quite pleasant)
string-literals-are-types feature to enforce that, given an event payload, the string arguments
given to createEvent
must match whatever is defined for BlogEvent
. The magic comes from using
the indexed access operator
(you'll have to search through that page to find it) T["field_here"]
syntax to match the string
literals, and some gymnastics to implement an Omit
type. This type states, in plainer words, every
field in D
except type
, event_namespace
and event_type
must be present in the passed
object. This is good for the programmer - if they pass in those fields in the data object, they get
overwritten anyway, leading to unexpected behaviour. By denying them in the body, we enforce a more
correct, safer way of creating events.
The code isn't as elegant as just providing a type, but now it means that our function properly
type checks the resulting object. This means no more typoed event name fields and no more
missing/mis-spelled data fields! This inelegance can also be tucked away in some library code,
leaving just the nice interface. The only caveat with the above implementation that I've found so
far is that the programmer must explicitly provide the type, or typechecking won't be "enabled".
There's a compiler option - --noImplicitAny
- which might help with this but I haven't tried it
yet.
Typescript is actually good if you don't slap any
s everywhere and leverage its type system
fully. People have a lot of strong opinions both ways about static type checking, but if it stops
broken stuff getting into production, then it absolutely should be another tool in your toolbox.