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):
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,
};
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 ofnumber
.Price
is basically another name fornumber
.
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 everystring
is a valid street addressage: number
: A age is more narrow than anynumber
, probably more something between 0 and 120zip: string
: Same as before, not everything should be acceptedphone: 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
asany
. 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 anumber
to aPrice
(wrap
) and fromPrice
tonumber
(unwrap
). Sincewrap
does not check the validity of the givennumber
, this function must not be exported (it must not be accessible from outside this file)prismPrice
: used to compose validations and to safely parse anumber
to aPrice
by using the provided functionisPrice
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 userefine
withisPrice
before usingwrap
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 anyzod
typeT
: Output type, in this casePrice
S
: Source type, in this casenumber
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