Last year I wrote about Jsonify
, a generic that models how a type changes as it goes through JSON serialization and deserialization. This is especially relevant for JavaScript Date
s, which get converted to strings in this process:
|
If you use a type on your server, Jsonify
tells you what that type will look like on your client:
|
The original post generated some strong reactions. Since I posted it, I've learned two things:
First, I didn't come up with this. In fact, Anders presented it at his keynote at the original TSConf, in 2018. He, in turn, based it on discussion on GitHub. What's surprising is that I was at that talk! But it must have gone in one ear and out the other. In any case, Jsonify
became much more compelling once TypeScript 3.7 introduced recursive type aliases in Nov. 2019.
Second, I've learned that Jsonify
isn't a good idea.
Why not? While it's neat that you can model a transformation like this in the type system, it wound up being quite annoying when my team put it into practice.
For example, if you have an API on the server that produces a Student
, then your client-side code should work in terms of Jsonify<Student>
:
|
As you pass the student
object around your application, you'll get long error messages any time you forget the Jsonify
:
|
Nothing about this error says "you forgot Jsonify
." We just had to learn "any time you see something about Date
and string
, it means you forgot a Jsonify
somewhere." In more realistic code, there can be many Date
objects that are deeply nested, leading to even longer, more confusing errors. These Date
fields typically weren't used by the function producing the error, so we weren't even gaining safety for our trouble.
After battling Jsonify
for a few months, we decided to get rid of it by eliminating Date
s from our API. Most of these were coming from our database. By default, node-postgres
converts Postgres date/timestamp columns to Date
objects. This makes a lot of sense as a default. But to keep our server and and client types equal, we decided to just use strings instead.
To make this work, we had to reconfigure the types returned by node-postgres:
|
We use pg-to-ts
(a fork of schemats
) to generate types from our database schema (which we consider a source of truth). So we had to adapt it with a --datesAsStrings
flag.
With these changes in place, our API types were fully unified: they were exactly the same on the server and the client:
|
So we could drop Jsonify
! Now we could write code like this error-free:
|
The lesson here is that you should prefer to unify your types rather than model small differences between them. By unifying your types, you'll save all the time and effort you would have spent getting the transformations exactly correct and applied in exactly the right places.
As another example, it's common to have snake_case
column names in your database and convert them to camelCase
for JS/TS variable names and types in your API:
|
As I discussed in my TypeScript Splits the Atom post, as of TypeScript 4.1 you can model this snake_case → camelCase transformation in the type system. But should you? Following the mantra of "unify rather than model small differences," clearly you should not! In this case you could either use snake_case
names in your API or use a tool like pg-camelcase to convert the snake_case
names to camelCase
as you load them from the database (you can do something similar with knex). In either case, you'll be able to forget about the type transformations entirely.
Of course, any rule comes with caveats.
First, this isn't always an option. You may need the two types if the database and the API aren't under your control. If this is the case, then modeling these sorts of differences systematically in the type system will help you find bugs in your transformation code. It's better than creating types ad-hoc and hoping they stay in sync.
Second, don't unify types that aren't representing the same thing! Say you have a tagged union, for example:
|
It would be counterproductive to "unify" these types:
|
This will make TypeScript less effective at finding bugs in your Response
-handling code. The two Response
types are fundamentally different, so they should not be unified. This rule is best applied to types that are fundamentally the same but superficially different.
If you find yourself creating lots of types that are only slightly different from one another, consider unifying them. You'll be happy you did!