The trouble with Jsonify: Unify types instead of modeling small differences

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 Dates, which get converted to strings in this process:

> d = new Date();
> JSON.parse(JSON.stringify(d))
'2021-04-07T01:07:48.835Z'

If you use a type on your server, Jsonify tells you what that type will look like on your client:

interface Student {
id: number;
name: string;
birthday: Date | null;
}
type T1 = Jsonify<Student>;
// type is {
// id: number;
// name: string;
// birthday: string | null;
// }

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>:

function getStudent(studentId: string): Promise<Jsonify<Student>> {
// ...
}

As you pass the student object around your application, you'll get long error messages any time you forget the Jsonify:

function displayStudent(student: Student) {
const {id, name, birthday} = student;
return `${id}: ${name} ${birthday}`;
}

async function renderStudent() {
const student = await getStudent('123');
displayStudent(student);
// ~~~~~~~
// Argument of type '{ id: number; name: string; birthday: string | null; }' is not assignable to parameter of type 'Student'.
// Types of property 'birthday' are incompatible.
// Type 'string | null' is not assignable to type 'Date | null'.
// Type 'string' is not assignable to type 'Date | null'. (2345)
}

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 Dates 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:

import {types} from 'pg';

types.setTypeParser(types.builtins.DATE, _.identity);
types.setTypeParser(types.builtins.TIMESTAMPTZ, _.identity);
types.setTypeParser(types.builtins.TIMESTAMP, _.identity);

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:

interface Student {
id: number;
name: string;
birthday: string | null; // Used to be Date | null
}
type T = Jsonify<Student>; // exactly the same!

So we could drop Jsonify! Now we could write code like this error-free:

function getStudent(studentId: string): Promise<Student> {
// ...
}

function displayStudent(student: Student) {
const {id, name, birthday} = student;
return `${id}: ${name} ${birthday}`;
}

async function renderStudent() {
const student = await getStudent('123');
displayStudent(student); // ok!
}

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:

interface StudentTable {
first_name: string;
last_name: string;
birth_date: string;
}

interface Student {
firstName: string;
lastName: string;
birthDate: string;
}

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:

interface ResponseSuccess {
status: 'ok';
payload: PayloadType;
}
interface ResponseError {
status: 'failed';
error: string;
}
type Response = ResponseSuccess | ResponseError;

It would be counterproductive to "unify" these types:

// Don't do this!
interface Response {
status: 'ok' | 'failed';
payload?: PayloadType;
error?: string;
}

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!

Like this post? Consider subscribing to my newsletter, the RSS feed, or following me on Twitter.
comments powered by Disqus
Effective TypeScript Book Cover

Effective TypeScript shows you not just how to use TypeScript but how to use it well. The book's 62 items help you build mental models of how TypeScript and its ecosystem work, make you aware of pitfalls and traps to avoid, and guide you toward using TypeScript’s many capabilities in the most effective ways possible. Regardless of your level of TypeScript experience, you can learn something from this book.

After reading Effective TypeScript, your relationship with the type system will be the most productive it's ever been! Learn more »