Building a Testable, Typed, Node Microservice (2 Part Series)
Many years ago, before I ever got started with Node, I used to write a fair bit of C and C++. While those languages have the benefit of type safety in some circumstances, relatively common patterns like pointer casting are still unsafe. Making unchecked assumptions about your data at runtime can have fun effects, like a wallpaper that bootloops your phone.
As a result, from early days I developed a kind of paranoia for including runtime-checks and assertions in my code as a way of ensuring that everything if something unexpected happened, the code would explode in a useful way, rather than in a confusing way, or worse, just silently corrupt data.
You can add testing (or just raw self-confidence) to try to avoid these checks, but in my experience some level of runtime checking is more useful than it is expensive.
A simple check would look something like this:
Or you can make it a bit more concise with Node assert.
Of course this only really works for non-object parameters. Asserting all of the properties of an object parameter quickly becomes a mess.
So I came up with a solution that seemed to work pretty well without being overly verbose. I'd create a class that validates it's members before construction, and then I could pass instances of that class around and just assert that the argument was an instance of that class.
Not perfect, technically you could still mutate the class outside of the constructor, but it was good enough for my purposes in a pre-Typescript world.
Some features of this approach:
- This solution centralises the validation of a given data model within a given model file, it's DRY
- It's only validated once at construction and then the rest of the code can essentially just trust it based on type
- Extra object values that are not necessary are silently stripped off at construction (may be a problem depending on how strict you want to be)
There are further ways to improve this that I won't get into deeply. The biggest improvement is that instead of writing assert statements inside the constructor, it's nicer to use something like ajv and jsonschema to do the validation. This standardizes the validation, and adds a ton of strictness if that's what you're going for.
For me, in my implementations, and this blog going forward, a model is a (mostly) immutable instance of a class that validates its member variables at construction, and can be assumed to only contain valid data from that point forward.
This allows you to pass model instances from service to service without re-checking all of the internal state, and serves as a centralised place to put all the validation logic associated with a given concept. In my designs, models are created anytime data crosses a system boundary (API to UI, or UI to API, or API to DB, etc), and this way you can be sure that everything is expecting the same data structure with the same constraints.
Creating new instances of classes at boundaries like this does have a computational cost, but that's usually minimal, and I'll talk later about what to do when it isn't.
For me, models are the fundamental, passive, immutable block of state that all other active abstractions use to communicate with each other.
So at some point in the last year I saw the light and took Typescript into my heart. I had resisted it because of the time-penalty during development caused by the compile step, but on the whole it's been a large improvement.
For those that haven't made the transition, my biggest points would be:
- Significantly fewer dumb-level bugs with less testing
- Way faster refactoring in a good IDE like Intellij
Of course the gotcha with Typescript is that all of that fancy type-safety stuff completely evaporates at runtime, by design. That's not to say it isn't useful in finding and fixing bugs during development, but it's not helping you in production. My non-typescript approach had been trying to address both, making development faster with better errors, and making production safer with validation. So switching entirely to Typescript types and abandoning runtime checks was not an option for me.
At the same time, I didn't want to duplicate my work by implementing both runtime and compile time type-checks everywhere. This seems like a waste.
So, as with all good engineering solutions, I settled on a compromise. I'd validate at runtime within my models, and let Typescript do the the rest of the work everywhere else. Sure that's not perfect, but I good enough was good enough.
There are a number of libraries and options for translating Typescript types to runtime checks, but I didn't really like any of them. They seemed like a lot of verbosity and work, basically re-implementing a runtime version of Typescript for every model.
Eventually I found class-validator and that proved to be the thing I needed. Create a regular Typescript class however you like, and then attach decorators with the validation and constraints to the member definitions. Before exiting the constructor, validate what you have initialised.
To make this easier, I created a base class that contains the validation logic that I extend for every instance of every model in my system. The core of the base class looks like this:
This does a few things:
- uses class-validator to validate the concrete class
- if there are any errors, collect them, format them, and throw them with an attached HTTP status code (I catch and relay this in my controller)
An example implementation of this class would look like:
With this class defined, you can just create an instance of it, and the omit asserting the types of function parameters.
And that's it!
From here I'll move onto the next level, using these validated models in connection with the DB.