zod and newtype-ts | Full type-safety in Typescript

6 September 2022

15 min read

Web development

In my journey to achieve full type safety in Typescript I came across newtype-ts.

This library allows to implement type-safe branded types (also called newtypes, opaque types, nominal types).

At the same time, I use (and suggest) zod to verify at runtime the type of your data.

Recently I started a quest to join these two solutions together and achieve an higher level of type-safety 🚀

In this post I am going to document my solution. I will also show you why this matters, and how it will make your code safer.


Here is the final solution (for copy and paste purposes):

price.new-type.ts
import { iso, Newtype, prism } from "newtype-ts";
import { z } from "zod";
import type { NewTypeInstance } from "./new-type";

type Price = Newtype<{ readonly Price: unique symbol }, number>;

const isPrice = (n: number) => n > 0;

const isoPrice = iso<Price>();
const prismPrice = prism<Price>(isPrice);

export type { Price };
export const PriceI: NewTypeInstance<number, Price> = {
  prism: prismPrice,
  zodType: z.number().refine(isPrice).transform(isoPrice.wrap),
  unwrap: isoPrice.unwrap,
};
new-type.ts
import { Prism } from "monocle-ts";
import { z } from "zod";

interface NewTypeInstance<S, T> {
  prism: Prism<S, T>;
  zodType: z.ZodEffects<z.ZodTypeAny, T, S>;
  unwrap: (t: T) => S;
}

export type { NewTypeInstance };

Typescript - Structural type system

The type system of typescript is structural: two types are considered the same if they share the same structure.

For example, the following types A and B are considered equal by the type system:

type A = {
  prop1: string;
  prop2: number;
}

type B = {
  prop1: string;
  prop2: number;
}

Practically speaking, any function that requires A as parameter will also accept B (or anything really that has at least prop1: string and prop2: number):

const myFun = (a: A) => { ... }

const a: A = { prop1: 'a', prop2: 1 };
const b: B = { prop1: 'b', prop2: 2 };

myFun(a); // 👍
myFun(b); // 👍
myFun({ prop1: 'any', prop2: 0 }); // 👍

This is great in all the most common cases.

Excess Property Checking

There are some cases in which you must be careful.

Let's say we have another type C. C is the same as A and B, but it also defines a property prop3: boolean:

type A = {
  prop1: string;
  prop2: number;
}

type B = {
  prop1: string;
  prop2: number;
}

// New
type C = {
  prop1: string;
  prop2: number;
  prop3: boolean;
}

Since C shares the same prop1 and prop2 with A and B, typescript allows to pass a variable with type C to myFun:

const myFun = (a: A) => { ... }

const a: A = { prop1: 'a', prop2: 1 };
const b: B = { prop1: 'b', prop2: 2 };
const c: C = { prop1: 'b', prop2: 2, prop3: true };

myFun(a); // 👍
myFun(b); // 👍
myFun({ prop1: 'any', prop2: 0 }); // 👍

myFun(c); // 👍👍👍

Be careful though! This only works when passing a variable that share the same structure of the input. If you instead try to pass a inline object literal you will get an error:

const myFun = (a: A) => { 0 }

const a: A = { prop1: 'a', prop2: 1 };
const b: B = { prop1: 'b', prop2: 2 };
const c: C = { prop1: 'b', prop2: 2, more: true };

myFun(a); // 👍
myFun(b); // 👍
myFun({ prop1: 'any', prop2: 0 }); // 👍
myFun(c); // 👍

myFun({ prop1: 'any', prop2: 0, props3: true }); // 💥

The error you get is the following:

Argument of type { prop1: string; prop2: number; props3: boolean; } is not assignable to parameter of type 'A'. Object literal may only specify known properties, and 'props3' does not exist in type 'A'.

This feature is called Excess Property Checking.

This feature is relevant in React when passing props. We are allowed to pass a variable as prop as long as it matches at least the component props type (<Component {...props }>). At the same time, we are not allowed to pass extra props when defining them inline.

const Component = (props: { a: number }) => (...)

const props = { a: 10, b: 'b', c: true };

<Component {...props} /> {/* 👍 */}
<Component a={props.a} b={props.b} c={props.c} /> {/* 💥 */}

Structural type system - What can go wrong?

A structural type system works great in most cases, especially when working with React. Nonetheless, it causes also some issues with type-safety, specifically when working with branded types.

Let us see an example. We defined a new type called Price. This type derives from number, so our first instinct is to define it as follows:

type Price = number;

Nonetheless, Price is not strictly any number. Generally a price is always greater than 0. Unfortunately, a structural type system allows to pass any number, even where Price was expected.

We say that Price is an alias of number. Price is basically another name for number.

type Price = number;

const addPrice = (p1: Price, p2: Price) => p1 + p2;

addPrice(10, -10); // 🙅‍♂️

What we want instead is a branded type.

A branded type is a type derived from a primitive (string) but which has a restricted set of allowed values.

newtype-ts for Branded types

newtype-ts is a library included in the fp-ts ecosystem.

newtype-ts allows to create branded types, working in combination with monocle-ts to also provide a handy API on top a the newly created type.

Creating a branded type for Price is easy:

import { Newtype, prism } from 'newtype-ts'

type Price = Newtype<{ readonly Price: unique symbol }, number>;

const isPrice = (n: number) => n > 0;

/* Create a `Prism`, used to check the validity of a `number` */
const prismPrice = prism<Price>(isPrice)

const priceOption1 = prismInteger.getOption(2) // 👍 (`Some`)
const priceOption2 = prismInteger.getOption(-2) // 👎 (`None`)

Using prismPrice we can check that a number is a valid Price before using it in our function. Since Price is not just a simple number, passing any number to a function that requires Price is not valid anymore:

type Price = Newtype<{ readonly Price: unique symbol }, number>;

const addPrice = (p1: Price, p2: Price) => p1 + p2;

addPrice(10, -10); // Not working, `number` is not `Price` 🪄

Why using branded types

If you pay close attention, in most situations in which you use string or number you could usually narrow down the type:

  • streetAddress: string: Not every string is a valid street address
  • age: number: A age is more narrow than any number, probably more something between 0 and 120
  • zip: string: Same as before, not everything should be accepted
  • phone: string: Well, you get what I am saying 💁🏼‍♂️

The problem here is significant: what happens if you pass an invalid zip in a request? In the "best" cases, the server will respond with an error. In the worst case, you will hijack the database and cause countless problems down the line 🤯.

Using branded types instead you are sure at compile type (👉 at compile type!) that if you have a Price the format is always correct.

Parsing types at runtime using zod

zod is a must-have (👉 must-have!) library in any typescript project. zod is used to validate types at runtime against an expected schema.

Why does this matter?

The typescript type system is unsound: typescript trusts you (the developer) with types. This is all well and good when we are working with our own internal types.

But what happens when we access external values (from an API for example)? Mayhem!

When you make an API call, you cannot be sure of the shape of the returned type (at compile time at least).

This means that the following code is wrong and potentially unsafe:

import axios from 'axios';

type ReturnType = {
  name: string;
  surname: string;
  address: {
    zip: string;
  }
}

const response = await axios.get<ReturnType>('/user');
const data = response.data; // Typed as `ReturnType` 🙅‍♂️

This code is equivalent to using the as keyword in typescript (const data = response.data as ReturnType). The problem is that we are not sure that the response will indeed be of type ReturnType.

What happens if the returned schema is incorrect and we try to access data.address.zip? Runtime error! 👻

That's because doing data.address will return undefined, and doing undefined.zip is an error in javascript 💁🏼‍♂️.

Using zod for parsing

What zod does is making sure that the response type is as expected before working with it internally.

zod acts as a safety layer between the messy outside world of APIs 👹, and the beautiful and completely type-safe wonderland inside our application 🌈

Using zod is similar to what we did previously with typescript only:

import axios from 'axios';
import { z } from "zod";

const returnTypeZod = z.object({
  name: z.string(),
  surname: z.string(),
  address: z.object({
    zip: z.string(),
  })
}); 

const response = await axios.get<any>('/user');
const dataToValidate = returnTypeZod.safeParse(response.data);

if (dataToValidate.success) {
  const validData = dataToValidate.data; // This is valid 🌈
}

We use the safeParse function of returnTypeZod (zod.object) to validate that the response type is indeed what we expect.

Note: For complete safety, we type the response from axios as any. Indeed, that is the correct type: the response can be anything!

Combining newtype-ts and zod

Finally, we reached the point in which we can combine newtype-ts and zod to build a complete type-safe validation layer.

First of all, here is the complete solution:

import { iso, Newtype, prism } from "newtype-ts";
import { z } from "zod";
import type { NewTypeInstance } from "./new-type";

type Price = Newtype<{ readonly Price: unique symbol }, number>;

const isPrice = (n: number) => n > 0;

const isoPrice = iso<Price>();
const prismPrice = prism<Price>(isPrice);

export type { Price };
export const PriceI: NewTypeInstance<number, Price> = {
  prism: prismPrice,
  zodType: z.number().refine(isPrice).transform(isoPrice.wrap),
  unwrap: isoPrice.unwrap,
};

Here NewTypeInstance is defined as follows:

import { Prism } from "monocle-ts";
import { z } from "zod";

interface NewTypeInstance<S, T> {
  prism: Prism<S, T>;
  zodType: z.ZodEffects<z.ZodTypeAny, T, S>;
  unwrap: (t: T) => S;
}

export type { NewTypeInstance };

Let's now look at each line one by one.

First of all, we define the NewType using newtype-ts:

type Price = Newtype<{ readonly Price: unique symbol }, number>;

We call the new branded type Price and we make it derive from number.

The second step is defining a validation function for Price. This function should return true only when a number is indeed a valid Price:

const isPrice = (n: number) => n > 0;

Third step, we use newtype-ts (actually monocle-ts) to define both an instance of Prism and Iso for Price:

const isoPrice = iso<Price>();
const prismPrice = prism<Price>(isPrice);
  • isoPrice: it allows to convert from a number to a Price (wrap) and from Price to number (unwrap). Since wrap does not check the validity of the given number, this function must not be exported (it must not be accessible from outside this file)
  • prismPrice: used to compose validations and to safely parse a number to a Price by using the provided function isPrice

Finally, we export the public API that allows to safely convert number to Price and Price back to number.

Furthermore, NewTypeInstance interface also requires a zodType used to connect together zod and newtype-ts:

export type { Price };
export const PriceI: NewTypeInstance<number, Price> = {
  prism: prismPrice,
  zodType: z.number().refine(isPrice).transform(isoPrice.wrap),
  unwrap: isoPrice.unwrap,
};

We also export type Price as type

Note: isoPrice.wrap here is potentially unsafe. Make sure to use refine with isPrice before using wrap

Using zodType with zod

We can look again at the definition of NewTypeInstance:

import { Prism } from "monocle-ts";
import { z } from "zod";

interface NewTypeInstance<S, T> {
  prism: Prism<S, T>;
  zodType: z.ZodEffects<z.ZodTypeAny, T, S>;
  unwrap: (t: T) => S;
}

export type { NewTypeInstance };

zodType is defined as z.ZodEffects<z.ZodTypeAny, T, S>:

  • z.ZodTypeAny: Allows this type to derive from any zod type
  • T: Output type, in this case Price
  • S: Source type, in this case number

Now we can use the branded type inside any zod object:

import {
  Price,
  PriceI,
} from "./price.new-type";

const zodSchema = z.object({
  price: PriceI.zodType, // `zod` 🤝 `newtype-ts`
});

type ZodSchema = z.output<typeof zodSchema>; // `{ price: Price }` ✨

Since we defined Price as output, the inferred schema from zod (using z.output) is of type Price.

Finally, to extract the number value from Price we have the unwrap function:

const priceToValidate = zodSchema.safeParse({ price: 10 });

if (priceToValidate.success) {
  const validPrice = priceToValidate.data.price;
  const extractNumber = PriceI.unwrap(validPrice); // 👈 Type `number`
}

newtype-ts vs zod brand

Recently zod introduced branded type as well (brand).

zod's brand has the same purpose of newtype-ts (with a different implementation):

import { z } from "zod";

const priceSchema = z.number().positive().brand<"Price">();
type Price = z.TypeOf<typeof priceSchema>;

const priceToValidate = priceSchema.safeParse(10);
if (priceToValidate.success) {
  // validPrice: number & z.BRAND<"Price">
  const validPrice = priceToValidate.data;
}

There are some differences between the two.

First of all, newtype-ts is part of the fp-ts ecosystem. As such, using newtype-ts is generally more convenient when you use fp-ts in your project as well.

Second, Price with newtype-ts cannot be casted to its primitive type. This makes validation required, there are no way to bypass it (while with brand you are allowed to use as):

type Price = Newtype<{ readonly Price: unique symbol }, number>;
const a = 0 as Price; // Compile time error, not possible 🙅‍♂️

const priceSchema = z.number().positive().brand<"Price">();
type Price = z.TypeOf<typeof priceSchema>;
const a = 0 as Price; // This is allowed, it works 👎

When using newtype-ts the type resulted from calling safeParse is still Price. With zod instead the type becomes number & z.BRAND<"Price">:

// With newtype-ts, we get `Price`
const priceToValidate = PriceI.zodType.safeParse(10);
if (priceToValidate.success) {
  // validPrice: Price ✨
  const validPrice = priceToValidate.data;
}

// With zod brand, we get `number & z.BRAND<"Price">`
const priceToValidate = priceSchema.safeParse(10);
if (priceToValidate.success) {
  // validPrice: number & z.BRAND<"Price"> 💁🏼‍♂️
  const validPrice = priceToValidate.data;
}

In this way, the result of zod gives you back a primitive type: validPrice extends number:

const sumOne = (a: number) => a + 1;

// newtype-ts
const priceToValidate = PriceI.zodType.safeParse(10);
if (priceToValidate.success) {
  // validPrice: Price
  const validPrice = priceToValidate.data;
  sumOne(validPrice); // Not working, is `Price` not `number` 🙅‍♂️
}

// zod
const priceToValidate = priceSchema.safeParse(10);
if (priceToValidate.success) {
  // validPrice: number & z.BRAND<"Price">
  const validPrice = priceToValidate.data;
  sumOne(validPrice); // It works, it's a `number` after all 💁🏼‍♂️
}

In this cases, newtype-ts is more strict: you must call unwrap to convert Price to number.


What are the downsides?

Achieving full type safety is an honorable deed. Nonetheless, type safety comes with a cost.

First of all, typescript is not a purely functional language. This in practice means that the language will not help you with types and type safety. If you make a typo or you introduce some impure function by accident, typescript will be fine with that.

Second, the amount of code to write will increase significantly. Ideally, you should never use primitive types (string, number) internally in your app. That's because it is nearly always possible to narrow the type to make it safer.

Well, this means that for every parameter in every request (more of less) you are supposed to create a new .new-type.ts 🤯

Finally, full type safety makes the app less flexible. Every time you need to make a new change, you need to check that all the types are respected and validated. This process will take much longer compared to a "usual" typescript app.

Well, when should I use this then?

This approach works great when you know exactly the features of the app you need to implement. If those features are stable and not subject to changes, it is worthwhile to make the app as type safe as possible.

This will require more time at the beginning, but it will make everything easier down the line.

Furthermore, type safety is suited for larger long-term projects. Having to do all this setup for a small app is not really worth it.

Type safety is a double edged sword. Know its rules in order to break them 🔥


That's all. I am always looking to improve my code to make it as reliable and type safe (functional) as possible.

If you found this post interesting or you want to suggest some improvement, head over to @SandroMaglione (me 👋)

Thanks for reading