[Typescript] Using Zod to do runtime type checking

Zod

Example: number:

import { expect, it } from "vitest";
import { z } from "zod";

const numberParser = z.number();

export const toString = (num: unknown) => {
  const parsed = numberParser.parse(num);
  return String(parsed);
};

// TESTS

it("Should throw a runtime error when called with not a number", () => {
  expect(() => toString("123")).toThrowError(
    "Expected number, received string",
  );
});

it("Should return a string when called with a number", () => {
  expect(toString(1)).toBeTypeOf("string");
});

 

Example Object:

const PersonResult = z.object({
  name: z.string(),
});

export const fetchStarWarsPersonName = async (id: string) => {
  const data = await fetch("https://swapi.dev/api/people/" + id).then((res) =>
    res.json(),
  );

  const parsedData = PersonResult.parse(data);

  console.log(parsedData) // the parseData will reudce to the type its understand,: {name: 'C-3PO'}

  return parsedData.name;
};

// TESTS

it("Should return the name", async () => {
  expect(await fetchStarWarsPersonName("1")).toEqual("Luke Skywalker");
  expect(await fetchStarWarsPersonName("2")).toEqual("C-3PO");
});

 

Example Array:

import { expect, it } from 'vitest';
import { z } from 'zod';

const StarWarsPerson = z.object({
  name: z.string(),
});

const StarWarsPeopleResults = z.object({
  results: z.array(StarWarsPerson),
});
//                            ^ 🕵️‍♂️

export const fetchStarWarsPeople = async () => {
  const data = await fetch('https://swapi.dev/api/people/').then((res) =>
    res.json()
  );

  const parsedData = StarWarsPeopleResults.parse(data);

  return parsedData.results;
};

// TESTS

it('Should return the name', async () => {
  expect((await fetchStarWarsPeople())[0]).toEqual({
    name: 'Luke Skywalker',
  });
});

 

Example: z.infer<typeof object>

import { z } from 'zod';

const StarWarsPerson = z.object({
  name: z.string(),
});

const StarWarsPeopleResults = z.object({
  results: z.array(StarWarsPerson),
});

export type PeopleResults = z.infer<typeof StarWarsPeopleResults>;
const logStarWarsPeopleResults = (
  data: PeopleResults
) => {
  //                                    ^ 🕵️‍♂️
  data.results.map((person) => {
    console.log(person.name);
  });
};

 

Example: Optional:

import { z } from 'zod';

const Form = z.object({
  name: z.string(),
  phoneNumber: z.string().optional(),
});

type FormType = z.infer<typeof Form>;

/*
type FormType = {
  phoneNumber?: string | undefined;
  name: string
}
*/

 

Example: default value:

import { expect, it } from 'vitest';
import { z } from 'zod';

const Form = z.object({
  repoName: z.string(),
  keywords: z.array(z.string()).default([]),   // set default value
});

export const validateFormInput = (values: unknown) => {
  const parsedData = Form.parse(values);

  return parsedData;
};

// TESTS

it('Should include keywords if passed', async () => {
  const result = validateFormInput({
    repoName: 'mattpocock',
    keywords: ['123'],
  });

  expect(result.keywords).toEqual(['123']);
});

it('Should automatically add keywords if none are passed', async () => {
  const result = validateFormInput({
    repoName: 'mattpocock',
  });

  expect(result.keywords).toEqual([]);
});

 

 

The Input is Different than the Output

We've reached the point with Zod where our input is different than our output.

In other words, you can generate types based on input as well as generating types based on the output.

For example, let's create FormInput and FormOutput types:

type FormInput = z.input<typeof Form>
/*
type FormInput = {
  keywords?: string[] | undefined;
  repoName: string;
}
*/
type FormOutput = z.infer<typeof Form>
/*
type FormOutput = {
  repoName: string;
  keywords: string[];
}
*/

 

Example Union type:

 


import { expect, it } from "vitest";
import { z } from "zod";

const Form = z.object({
  repoName: z.string(),
  //privacyLevel: z.union([z.literal("private"), z.literal("public")]),
  privacyLevel: z.enum(["private", "public"])
});

export const validateFormInput = (values: unknown) => {
  const parsedData = Form.parse(values);

  return parsedData;
};

// TESTS

it("Should fail if an invalid privacyLevel passed", async () => {
  expect(() =>
    validateFormInput({
      repoName: "mattpocock",
      privacyLevel: "something-not-allowed",
    }),
  ).toThrowError();
});

it("Should permit valid privacy levels", async () => {
  expect(
    validateFormInput({
      repoName: "mattpocock",
      privacyLevel: "private",
    }).privacyLevel,
  ).toEqual("private");

  expect(
    validateFormInput({
      repoName: "mattpocock",
      privacyLevel: "public",
    }).privacyLevel,
  ).toEqual("public");
});

 

Example: string-specific validations

z.string().max(5);
z.string().min(5);
z.string().length(5);
z.string().email();
z.string().url();
z.string().uuid();
z.string().cuid();
z.string().regex(regex);
z.string().startsWith(string);
z.string().endsWith(string);

// trim whitespace
z.string().trim();

// deprecated, equivalent to .min(1)
z.string().nonempty();

// optional custom error message
z.string().nonempty({ message: "Can't be empty" });
import { expect, it } from "vitest";
import { z } from "zod";

const Form = z.object({
  name: z.string().min(1),
  phoneNumber: z.string().min(5).max(20).optional(),
  email: z.string().email(),
  website: z.string().url().optional(),
});

export const validateFormInput = (values: unknown) => {
  const parsedData = Form.parse(values);

  return parsedData;
};

// TESTS

it("Should fail if you pass a phone number with too few characters", async () => {
  expect(() =>
    validateFormInput({
      name: "Matt",
      email: "matt@example.com",
      phoneNumber: "1",
    }),
  ).toThrowError("String must contain at least 5 character(s)");
});

it("Should fail if you pass a phone number with too many characters", async () => {
  expect(() =>
    validateFormInput({
      name: "Matt",
      email: "matt@example.com",
      phoneNumber: "1238712387612387612837612873612387162387",
    }),
  ).toThrowError("String must contain at most 20 character(s)");
});

it("Should throw when you pass an invalid email", async () => {
  expect(() =>
    validateFormInput({
      name: "Matt",
      email: "matt",
    }),
  ).toThrowError("Invalid email");
});

it("Should throw when you pass an invalid website URL", async () => {
  expect(() =>
    validateFormInput({
      name: "Matt",
      email: "matt@example.com",
      website: "/",
    }),
  ).toThrowError("Invalid url");
});

it("Should pass when you pass a valid website URL", async () => {
  expect(() =>
    validateFormInput({
      name: "Matt",
      email: "matt@example.com",
      website: "https://mattpocock.com",
    }),
  ).not.toThrowError();
});

 

Example:  Composing Schemas

// From 
const User = z.object({
  id: z.string().uuid(),
  name: z.string(),
});

const Post = z.object({
  id: z.string().uuid(),
  title: z.string(),
  body: z.string(),
});

const Comment = z.object({
  id: z.string().uuid(),
  text: z.string(),
});
// Notice that id is present in each.
const ObjectWithId = z.object({
    id: z.string().uuid()
})

const User = ObjectWithId.extend({
  name: z.string()
})

const Post = ObjectWithId.extend({
  title: z.string(),
  body: z.string()
})

const Comment = ObjectWithId.extend({
  text: z.string()
})
const ObjectWithId = z.object({
    id: z.string().uuid()
})

const User = ObjectWithId.merge(
  z.object({
      name: z.string()
  })
)

const Post = ObjectWithId.merge(
  z.object({
    title: z.string(),
    body: z.string()
  })
)

const Comment = ObjectWithId.extend(
  z.object(
    {
      text: z.string()
    }
  )
)

 

Transform Data from Within a Schema

Another useful feature of Zod is manipulating data from an API response after parsing.

To see this in action, we're going back to our Star Wars example.

Recall that we created StarWarsPeopleResults with a results array of StarWarsPerson schemas.

When we get the name of a StarWarsPerson from the API, it's their full name.

What we want to do is add a transformation on StarWarsPerson itself

import { expect, it } from "vitest";
import { z } from "zod";

const StarWarsPerson = z.object({
  name: z.string(),
  //name: z.string().transform(name => `Awesome ${name}`)
}).transform((person) => ({
  ...person,
  nameAsArray: person.name.split(" ")  
}));

const StarWarsPeopleResults = z.object({
  results: z.array(StarWarsPerson),
});

export const fetchStarWarsPeople = async () => {
  const data = await fetch("https://swapi.dev/api/people/").then((res) =>
    res.json(),
  );

  const parsedData = StarWarsPeopleResults.parse(data);

  return parsedData.results;
};

// TESTS

it("Should resolve the name and nameAsArray", async () => {
  expect((await fetchStarWarsPeople())[0]).toEqual({
    name: "Luke Skywalker",
    nameAsArray: ["Luke", "Skywalker"],
  });
});

 

posted @ 2022-10-30 17:40  Zhentiw  阅读(134)  评论(0)    收藏  举报