Конечные типобезопасные API с TypeScript и общими схемами Zod

Валидация данных абсолютно необходима. Это могут быть данные, полученные из API, данные, отправленные в API в теле запроса, или любая другая операция ввода-вывода. В этой статье будет представлен метод использования TypeScript и Zod для создания общих схем между фронтендом и бэкендом. Общие схемы позволят не только достичь необходимого уровня валидации данных, но и предоставят такие инструменты, как автоматически генерируемые общие типы, которые значительно повышают производительность!

Давайте создадим максимально простое приложение todo, пройдясь по каждой строчке кода, относящейся к валидации данных.

Следите за кодом на GitHub, где я разместил простой пример такого приложения, созданного с помощью Next.js.

Почему стоит использовать Zod?

Вы можете использовать любую другую библиотеку валидации, например, другие популярные варианты из трех букв, yup или joi. Я настоятельно рекомендую выбрать Zod, поскольку, на мой взгляд, она предоставляет отличный набор утилит, поддерживает практически все типы, которые вы только можете пожелать, имеет отличную поддержку TypeScript и короткий, легко запоминающийся API.

Особенно для людей, привыкших к типам TypeScript, схемы Zod действительно делают все возможное, чтобы они чувствовали себя как обычные схемы TypeScript. Например, все свойства объекта являются обязательными, если они явно не помечены как .optional() или .nullable(), подобно тому, как в TypeScript-типах требуется явно объявить свойство как необязательное с помощью ?, | undefined или | null. При использовании других библиотек проверки данных вам, возможно, придется постоянно помнить о необходимости набрать .string({ required: true }), что кажется не совсем естественным.

Определение схемы и ее автоматически генерируемых типов

Давайте создадим простое приложение Todo, чтобы проиллюстрировать, как Zod используется как в бэкенде, так и во фронтенде. Сначала определим схему для элемента Todo. Мы объявляем схему объекта Zod с помощью метода z.object(). Затем мы можем определить каждое свойство и его типы, каждый из которых может быть уточнен с помощью цепочки методов этих типов.

Важной частью определения схемы для достижения максимальной типобезопасности является ее совместное использование между фронтендом и бэкендом. Это можно сделать, например, с помощью внутреннего пакета npm или монорепо. В некоторых случаях, например, в Next.js, вы просто помещаете схему, например, в папку /lib/schemas, и она автоматически становится доступной как для фронтенда, так и для бэкенда. Это предотвращает необходимость дублирования схемы и поддержания двух идентичных схем. (Опять же, легкий источник ошибок: "Я просто быстро изменю это свойство на необязательное...", - можете сказать вы и забыть изменить другую копию схемы).

import { z } from "zod";

export const todoSchema = z.object({
    id: z.string(),
    done: z.boolean(),
    text: z.string().min(1), // min(1) disallows empty strings
    important: z.boolean().optional(), 
    createdAt: z.string(),
    updatedAt: z.string(),
})

// For later purposes, we also define a schema for an array of todos
export const todosSchema = z.array(todoSchema);
Вход в полноэкранный режим Выход из полноэкранного режима

Далее мы определим тип для todos. Обычно вы можете начать создание типа, соответствующего схеме, с export type Todo = { done: boolean; ... }, однако это не только не нужно, но и непродуктивно, поскольку требует поддерживать две версии одной и той же схемы. Вместо этого вы можете использовать Zod для автоматической генерации схемы путем вывода типов (что Zod умеет делать очень хорошо).

// .../schemas/todo.schema.ts
export type Todo = z.TypeOf<typeof todoSchema>;
Вход в полноэкранный режим Выход из полноэкранного режима

Совет: Вместо экспорта этих типов вы можете по желанию объявить следующий тип в вашем файле types.d.ts или любом другом файле .d.ts, который включен в ваш tsconfig.json. Это означает, что вам больше никогда не придется импортировать ваши типы с помощью import { Todo } из "path/to/todo". Однако объявление типов таким образом означает, что вам придется импортировать все зависимости в линию. Это немного некрасиво, но, на мой взгляд, полезно в долгосрочной перспективе.

// types.d.ts
type Todo = import("zod").TypeOf<
  typeof import("./lib/schemas/todo.schema")["todoSchema"]
>;
Вход в полноэкранный режим Выход из полноэкранного режима

Поразмышляем о силе общих схем в сочетании с автоматически выводимыми типами: без них вам пришлось бы хранить по одной копии схемы и интерфейса в бэкенде и фронтенде, что привело бы к четырем различным схемам, определяющим одно и то же, и каждая из них требовала бы обновления при необходимости.

Безопасная выборка типов с вашей новой схемой и типами!

Давайте приступим к реализации API для обслуживания todos и функций фронтенда, необходимых для их потребления! Мы создадим простую конечную точку для обслуживания всех todos.

export default async function handler(req: Request, res: Response) {
    if (req.method === "GET") {
        const todos = await getAllTodosFromDatabase();
        res.json(todos);
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Старый способ получения этих данных безопасным с точки зрения типов способом подразумевал использование типизации. Некоторые проверки типов с помощью if (data !== null) или if (typeof data === "object") или других длинных способов проверки данных могут присутствовать и должны автоматически сигнализировать разработчику, что требуется лучшее решение.

// BAD!
async function fetchTodos(): Promise<Todo[]> {
    const response = await fetch("/api/todos");
    const json = await response.json();
    return json as Todo[];
}
Вход в полноэкранный режим Выход из полноэкранного режима

Это может стать легким источником ошибок. Вы предполагаете, что ваш бэкенд поставляет объекты определенной формы. И вдруг массив не существует, все объекты в нем - null, или у некоторых объектов может отсутствовать свойство id. Вы не знаете и не можете доверять, пока не проведете проверку. Именно здесь в игру вступает схема.

Валидация данных без применения типовых схем и с гораздо большей уверенностью в собственном коде может быть достигнута всего несколькими строками кода.

// Good.
async function fetchTodos(): Promise<Todo[]> {
    const response = await fetch("/api/todos");
    const json = await response.json();

    const parsed = todosSchema.safeParse(json);
    if (parsed.success) {
        return parsed.data;
    }

    // Handle errors
    console.error(parsed.error)
    return [];
}
Вход в полноэкранный режим Выход из полноэкранного режима

Используя метод safeParse, мы можем легко проверить, что данные имеют правильную форму, и вернуть их как есть, без необходимости введения типов - Zod обрабатывает их за вас. В случае ошибки, parsed.success будет false, а свойство parsed.error будет содержать результирующую ошибку. Обработка ошибок оставлена на усмотрение читателя, однако возврат пустого массива в качестве значения по умолчанию и запись ошибки в журнал - это уже начало.

Вот так! Теперь у нас есть простой API для предоставления данных правильного типа и клиентская функция для их получения с проверкой. Все просто!

Обновление и создание элемента todo

Давайте рассмотрим обратное направление: сервер проверяет данные, полученные от клиента. И снова мы можем определить новые общие схемы. На этот раз схемы определяют форму объекта, отправляемого в качестве тела запроса к API. Затем их можно использовать как для проверки на сервере, так и для определения правильной формы на фронтенде. Давайте создадим схемы для обновления и создания элементов todo (с автоматически определяемыми типами).

// lib/schemas/todo.schema.ts

export const todoUpdateSchema = z.object({
  id: z.string(),
  done: z.boolean(),
});

export const todoCreateSchema = z.object({
  text: z.string(),
  important: z.boolean(),
});

export type TodoUpdate = z.TypeOf<typeof todoUpdateSchema>;
export type TodoCreate = z.TypeOf<typeof todoCreateSchema>;
Вход в полноэкранный режим Выход из полноэкранного режима

Далее определим простые обработчики в конечной точке для этих методов.

// pages/api/todos.ts

export default async function handler(req: Request, res: Response) {
    // ...
  if (req.method === "PATCH") {
    const body = todoUpdateSchema.parse(req.body);
    updateTodoInDatabase(body.id, body.done);
    return res.status(200).end();
  }

  if (req.method === "POST") {
    const body = todoCreateSchema.parse(req.body);
    createTodoInDatabase(body);
    return res.status(200).end();
  }
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Здесь вместо метода safeParse() мы используем более прямой метод - метод parse(). parse() возвращает проверенные данные в правильной форме и отбрасывает недопустимые данные. Иногда это предпочтительнее, так как в результате получается меньше строк и легче читать код, но обработка ошибок остается в блоке catch. Например, на сервере, где ошибки могут обрабатываться общей функцией обработчика ошибок, это может быть хорошим вариантом (или если вы просто предпочитаете try { schema.parse(json) } catch (e) { } вместо const parsed = schema.safeParse(json); if (parsed.success) { } else { }!

Использовать эти маршруты на фронтенде так же просто, как определить следующие функции, типы которых уже удобно доступны и точно такие же типы, которые использует бэкенд.

async function createTodo(body: TodoCreate) {
    await fetch("/api/todos", {
        body: JSON.stringify(body),
        headers: { "Content-type": "application/json" },
        method: "POST",
    })
}

async function updateTodo(body: TodoUpdate) {
    await fetch("/api/todos", {
        body: JSON.stringify(body),
        headers: { "Content-type": "application/json" },
        method: "PATCH",
    })
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Рассмотрим

Теперь мы закончили! Осталось только подключить функции updateTodo, createTodo и fetchTodos к коду фронтенда и развить эту идею.

Шаги:

  1. Создайте схему с помощью Zod (разделяемую между кодом фронтенда и кодом бэкенда).
  2. Получение автоматических типов с помощью Zod's z.TypeOf<typeof schema> type inference (также разделяется).
  3. Проверяйте данные при получении данных или отправке данных на сервер (используйте .parse() или .safeParse() в зависимости от ваших предпочтений).

И результаты:

  • Автоматически генерируемые типы
  • Общие схемы и типы между кодом фронтенда и бэкенда
  • Полная уверенность в достоверности ваших данных
  • Полная проверка достоверности данных с помощью всего нескольких строк кода
  • Отсутствие необходимости поддерживать симметричные схемы и типы в нескольких местах.

Код, показанный в этой статье, очевидно, является минимальным кодом самого простого примера, но расширить его на более крупные приложения не только легко, но и, честно говоря, необходимо. Ни одно приложение, большее, чем todo-приложение, созданное для изучения нового JS-фреймворка, никогда не должно передавать данные, которые не были проверены. Более того, попытки проверить данные без общих схем Zod - это просто лишняя работа, которая может даже стать потенциальным источником ошибок.

Оцените статью
devanswers.ru
Добавить комментарий