Better JSON types in TypeScript

This is a post on how to abuse TypeScript’s type system to write safer code.

JSON.stringify doesn’t serialise every value, for example, dates are turned into strings, functions and symbols are omitted. Which means when we read a value from a parsed JSON, we have to do some manual conversions.

type User = {
    age: number,
    name: string,
    hi: ()=> void,
    createdAt: Date,
    friends: Array<User>
}

function userFromJSON(v: object): User {
    return {
        age: v.age,
        name: v.name,
        createdAt: new Date(v.createdAt), // string -> date
        hi: ()=>{}, // v.hi does not exist so we have to create a new function
        friends: v.friends.map(userFromJSON),
    }
}

This is not very safe, the type object -> User does not tell what’s the expected input so it’s easy to be misused. What if there is a type level function that converts a type to its JSON encoded type, such that we can write like this?

function userFromJSON(v: AsJSON<User>): User { ... }
// We want something like this:
type AsJSON<User> = {
    age: number,
    name: string,
    createdAt: string,
    friends: Array<AsJSON<User>>
}

Let’s try to write it. The first thing would be dealing with values, Typescript has conditional types, it’s basically an if-then-else in type level.

type AsJSON<T> = 
    T extends Date ? string :
    T extends Function ? undefined :
    T extends Symbol ? undefined :
    // We will define these AsJSONArray and AsJSONObj later
    T extends Array<any> ? AsJSONArray<T> :
    T extends object ? AsJSONObj<T> :
    T;

Next, we will have to define AsJSONObj<T> which should converts an object’s value into its AsJSON correspondent. This should be rather simple.

type AsJSONObj<T extends object> = {
    [K in keyof T]: AsJSON<T[K]>
};

Defining AsJSONArray is more complicated but it’s not impossible. We want to turn an AsJSON<Array<T>> into an Array<AsJSON<T>>, which means we have to extract the T out of a Array<T> type. It’s possible with Type inference in conditional types, like this:

type Inspect<T extends Array<any>> = T extends Array<infer X> ? X : never;
// Inspect<Array<number>> == number

The AsJSONArray type should be something like this, except it’s not working. TypeScript complains about circular reference, then I don’t understand why is AsJSONObj fine.

// This does not work.
type AsJSONArray<T extends Array<any>> = Array<AsJSON<Inspect<T>>>

But it’s ok, we can work around that. Type inference with interface is deferred.

interface AsJSONArray<T extends Array<any>> extends Array<AsJSON<Inspect<T>>> {}

Now with the type completed, we can ask TypeScript to infer the type to see if it works.

Screen-Shot-2019-08-08-at-13.04.38

Now the function userFromJSON(v: AsJSON<User>): User { ... } is much more meaningful and safer.

But why should we stop here? I personally think we should not serialise any object with functions inside, it’s omitted silently and we should avoid that. What if it’s possible to make such usage a type error? I want something like this:

safeToJSON({foo: 'bar'}) // ok
safeToJSON({foo: 'bar', baz: ()=>{}}) // type error

The first step would be finding all the keys of “invalid value”.

type InvalidKeys<T> = {
    [K in keyof T]: AsJSON<T[K]> extends undefined ? K : never
}[keyof T];
// InvalidKeys<{foo: string}> == never
// InvalidKeys<{
//     foo: string,
//     bar: ()=>void,
//     baz: Symbol
// }> == 'bar' | 'baz'

This is a bit tricky, we first create an object which maps every key in T to some values, the value is AsJSON<T[K]> extends undefined ? K : never. We then use [keyof T] to retrieve all the keys. You can think of it like this:

InvalidKeys<{foo: 'bar', baz: ()=>{}}> == {
  foo: never,
  bar: 'bar'
}['foo' | 'bar']

Finally we can make a type and function that only accepts types that are valid,

type JSONable<T> = InvalidKeys<T> extends never ? T : never;
function safeToJSON<T>(v: JSONable<T>): string {
    return JSON.stringify(v);
}

safeToJSON({hi: 1}); // works
safeToJSON({hi: 1, foo: ()=>{}}); // type error
// Type '() => void' is not assignable to type 'never'.

While it’s pretty good now, we can make it more user friendly by tweaking the error message a bit. Instead of never, we can return a useless type like this, which would give us slightly better error message.

type ObjectContainsInvalidKeys<T> = {_:void}
type JSONable<T> = InvalidKeys<T> extends never ? T : ObjectContainsInvalidKeys<InvalidKeys<T>>;

Screen-Shot-2019-08-08-at-13.53.12


The code above is available here, you can try to play with it at the playground.

Finally, here is a homework if you are interested. The InvalidKeys type only looks for keys and values one level down, for example safeToJSON({foo: [{bar: ()=>{}}]}) does not have any type error. Try to fix it?