Everyone knows that TypeScript is type-safe, right? So what happens when I do something like this?
const json = `{"value": 2}`;
const data = JSON.parse(json) as { value: number };
data
in this case is typed as {value: number}
, correctly.
If I were to have code that did data.foo
, I would get an appropriate type error: Property 'foo' does not exist on type '{ value: number; }'.ts(2339)
So, what happens if I change the type, without changing the actual underlying JSON?
const json = `{"value": 2}`;
const data = JSON.parse(json) as { foo: number };
data.foo;
No more type error! If were to run this, data.foo
would evaluate to undefined
.
But lets go a step further.
data.foo.toString()
. toString()
is a valid method on a number
, and this is what TypeScript thinks there is (because that’s what we told it).
If you were compile this with tsc
, the compilation would succeed … but look what happens when you run the compiled javascript file:
console.log(data.foo.toString());
^
TypeError: Cannot read properties of undefined (reading 'toString')
A TypeError!
We need a better way to type values such as JSON, where at runtime they can be anything.
Enter Zod
Zod is “TypeScript-first schema validation with static type inference.”
Let’s see it in practice.
Instead of defining a type with the as
keyword, we define a schema with Zod.
We then safeParse
our data using the schema:
import { z } from "Zod";
const json = `{"value": 2}`;
const schema = z.object({
value: z.number(),
});
const parsed = schema.safeParse(JSON.parse(json));
if (parsed.error) throw new Error("Some Error");
const data = parsed.data;
data.value.toString();
Now data
is entirely type-safe. If the schema validation fails, parsed.error
would true
, and we would get the early return. parsed.data
cannot be undefined, we can be sure that it is a number, and that the methods we run on it will not fail—at least due to a type error.
More complex cases
Zod is very powerful.
Here is a more advanced case: say you have an API which can either return {success: true, value: 42}
or it can return {success: false, reason: "some reason"}
.
If success is true, we want to access the value
, if false, then maybe log the reason
.
Here is how you would type this:
type MyUnionType =
| {
success: true;
value: number;
}
| {
success: false;
reason: string;
};
Besides this type definition, you now have to validate that any JSON you might receive actually conforms to this type.
Here is how you might do it in Zod:
import { z } from "Zod";
const schema = z.discriminatedUnion("success", [
z.object({ success: z.literal(true), value: z.number() }),
z.object({ success: z.literal(false), reason: z.string() }),
]);
type UnionTypeFromZod = z.infer<typeof schema>;
If you inspect UnionTypeFromZod
, you’d see it exactly matches MyUnionType
. But we just had to write the validation schema, and let Zod infer the type! You write the parser and get the type for free!
You’ll need to check out the documentation at Zod.dev to find out exactly how powerful this tool is, but here are a few of my favorites:
z.string().email(); // makes sure a string is formatted as an email
z.enum(["value_a", "value_b"]); // make sure it is one of the given types
z.number().step(0.01); // make sure a number isn't subdived past one hundredth
z.number().positive(); // makes sure a number is positive
z.string().optional(); // fields in Zod are mandatory by default.
You can also transform fields while validating them:
const emailToDomain = z
.string()
.email()
.transform((val) => val.split("@")[1]);
emailToDomain.parse("joseph.lozano@testdouble.com"); // => testdouble.com
Zod is a very powerful tool, and a great way to get increased type safety while writing TypeScript.
A concrete example
Let’s see how it’s used to parse a real JSON response from the Deck of Cards API. First, lets define schemas for the cards:
// Our cardValues and cardSuits are defined as consts, which we can then
// extract the `CardValueCode` and `CardSuitCode` types from using
// `keyof typeof`.
const cardValues = {
"2": "2",
"3": "3",
"4": "4",
"5": "5",
"6": "6",
"7": "7",
"8": "8",
"9": "9",
"0": "10",
J: "JACK",
Q: "QUEEN",
K: "KING",
A: "ACE",
} as const;
type CardValueCode = keyof typeof cardValues;
const cardSuits = {
C: "CLUBS",
S: "SPADES",
H: "HEARTS",
D: "DIAMONDS",
} as const;
// Becomes the union type: `"C" | "S" | "H" | "D"`
type CardSuitCode = keyof typeof cardSuits;
// `codeSchema` is a bit complex so define it separately
const codeSchema = z
.string()
.length(2)
// Use `refine` because it has custom logic for validation
.refine((val) => {
const [cardValue, suitCode] = val.split("");
return (
// Pass `cardValue as CardValueCode` because `Object.keys(cardValues)` is
// strongly typed as an array with `CardValueCode`. Likewise for the suits.
Object.keys(cardValues).includes(cardValue as CardValueCode) &&
Object.keys(cardSuits).includes(suitCode as CardSuitCode)
);
});
const cardSchema = z.object({
code: codeSchema,
// Like refine, the `.url()` helper doesn't add any additional type
// information, but is useful for runtime validation.
image: z.string().url(),
images: z.object({
svg: z.string().url().endsWith(".svg"),
png: z.string().url().endsWith(".png"),
}),
value: z.nativeEnum(cardValues),
suit: z.nativeEnum(cardSuits),
});
So, Zod has not only validated our JSON response, but has done it in a composable manner, while automatically extracting type information. In fact, the only types we had to write were CardValueCode
and CardSuitCode
. Zod was able to infer everything else for us.
For these reasons, Zod is quickly gaining popularity, and already has integrations with many tools, such as react-hook-form for validating forms, and tRPC, for type-safe data fetching. If you have any unknown data coming into your system, Zod is the best way to validate it, and get type information while writing only one schema.