DEV Community

Cover image for How to enforce type-safety at the boundary of your code
Mathias D
Mathias D

Posted on

How to enforce type-safety at the boundary of your code

I recently started digging more into TypeScript. The structural typing approach in TypeScript is interesting and often a little bit surprising for developers coming from languages with a reified, nominal type system like Kotlin and Java.

I realised that it is very important to understand that types in TypeScript are completely erased at runtime.

Let's look at the following code. We are receiving JSON input and want to parse this into an object.

type User = {
  email: string;
  phone: string | null;
  age: number | null;
};

const json = '{"id": "some"}';

const maybeNoUser = JSON.parse(json) as User;

console.log(maybeNoUser.email);

// πŸ‘‡πŸ’₯ TypeError: Cannot read property 'toLocaleLowerCase' of undefined
console.log(maybeNoUser.email.toLocaleLowerCase());
Enter fullscreen mode Exit fullscreen mode

In Kotlin similar code would already fail while parsing the Json string into an object of type User. But here the code happily executes and only fails with a TypeError at the second log statement.

The as type assertion is basically telling the compiler to should shut up and that you know what you are doing. There is no checking performed - it has no runtime impact at all. It is not in the slightest similar to a type cast in e.g. Java. And because types are erased at runtime the type system cannot help us out here.

What we did above looks OK at first sight and also the TypeScript compiler is satisfied with it. Not even ESLint complains. But this can still be really bad in a real-world code base.
We are trusting that the Json is representing a User. If the input does not match our expectations we might get arbitrary problems in a totally different part of our code. Such errors will be tough to understand.

So what should we do here? Exactly, let's get our hands dirty and write some good old validation code to make sure the user object is satisfying our expectations.

type User = {
  email: string;
  phone: string | null;
  age: number | null;
};

const input = '{"email": "some@test.com", "age":"some"}';

const parseUser = (json: string): User => {
  const user = JSON.parse(json) as User;
  if (!user.email) {
    throw new Error('missing email');
  }
  if (user.age && typeof user.age !== 'number') {
    throw new Error('age must be a number');
  }
  return user;
};

// πŸ‘‡πŸ’₯ Error: age must be a number
const user = parseUser(json);
Enter fullscreen mode Exit fullscreen mode

All right - this is much safer. But honestly - the code in parseUser almost hurts. It is repetitive code that nobody likes to write. It is error prone and it is cumbersome to check every possible case. Even in our trivial case a complete implementation would need a lot more code than given in the example above. Also, everything we are checking in parseUser is already expressed in our User type. The validation logic is duplicating this. There has to be a better way.

Fortunately, there is zod for the win.

Zod is a TypeScript-first schema declaration and validation library.

Zod lets you declare schemas describing your data structures. These schemas can then be used to parse unstructured data into data that conforms to the schema. Sticking to our example above this could look like this:

import * as z from 'zod';

const userSchema = z
  .object({
    email: z.string(),
    phone: z.string().optional(),
    age: z.number().optional(),
  })
  .nonstrict();

type User = z.infer<typeof userSchema>;

const input = '{"email": "some@test.com", "age":"some"}';

/* πŸ‘‡πŸ’₯ 
[
  {
    code: 'invalid_type',
    expected: 'number',
    received: 'string',
    path: ['age'],
    message: 'Expected number, received string',
  },
]; */

const user = userSchema.parse(JSON.parse(input));
Enter fullscreen mode Exit fullscreen mode

I really like the DSL for schema declaration. It is hardly more complex than defining a type in Typescript. And we can even use it to infer a type from it that we can use in our function signatures. This way the usage of zod does not leak into our whole code base. The nonstrict() option generates a schema that allows additional properties not defined in the schema. This is definitely a best-practice when parsing Json data.

Zod also takes advantage of the structural typing characteristics of TypeScript. So you can derive similar types from a single schema. This can help e.g. when implementing a function to save a user. Such functions usually take an object, generate an id, save the object and return the object along with the id.

import * as z from 'zod';
import { v4 as uuid } from 'uuid';

const userEntitySchema = z
  .object({
    id: z.string().uuid(),
    email: z.string(),
    phone: z.string().optional(),
    age: z.number().optional(),
  })
  .nonstrict();
const userSchema = userEntitySchema.omit({ id: true });

type UserEntity = z.infer<typeof userEntitySchema>;
type User = z.infer<typeof userSchema>;

const input = '{"email": "some@test.com", "age":30}';

const saveUser = (user: User): UserEntity => ({
  id: uuid(),
  ...user,
});

const user = userSchema.parse(JSON.parse(input));
const userEntity = saveUser(user);

console.log(userEntity);
Enter fullscreen mode Exit fullscreen mode

Using omit we could just create a new schema out of the existing one. Also pick exists to add to an existing schema. And again - we did not have to duplicate any knowledge about our types.

I think this is really a neat tool that I recommend to use whenever potentially type-unsafe data is entering our code. Be it the Json input coming in via a REST invocation, or the result of a DynamoDB query. Zod has much more to offer then what I described here. So I can only encourage you to check out the excellent documentation.

Further reading:

Oldest comments (0)