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.
data, there are three fields common to all events which denote which type of event it
event_namespace- the namespace (domain) from which this event was emitted (
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
event_namespace are not used.
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
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 (
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
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
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
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
above. This is pretty terrible. We should be pushing the programmer into the
pit of success!
Into the pit - safely
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.
// 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
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
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
Typescript is actually good if you don't slap
anys 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.