TL;DR: внешние данные должны проверяться во время выполнения.

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

Из этой статьи вы узнаете, почему TypeScript позволяет писать код, который может не работать во время выполнения, и как Zod предотвращает ошибки, связанные с данными.

Назначение TypeScript

Как уже говорилось выше, идея TypeScript заключается в том, чтобы отслеживать структуру и тип любых данных по всему коду. Это должно не только обеспечивать автодополнение в IDE, но и предотвращать недопустимые операции, которые могут привести к ошибкам во время выполнения. Теоретически все возможные ошибки во время выполнения могут быть предсказаны и выявлены во время компиляции TypeScript. Но на деле это не так.

Как TypeScript выполняет свое назначение?

В действительности главная цель TypeScript — повысить производительность. Это означает, что TypeScript всегда будет отдавать предпочтение производительности, а не безопасности.

Убедительной демонстрацией этого является тип any. Он существует, хотя общепринято его не использовать. Однако отсутствие в коде any не означает, что приложение защищено от ошибок во время выполнения. Взгляните на следующий фрагмент:

const obviouslyAnArticle: Article = JSON.parse(input); // вводом является строка

Поскольку возвращаемый тип JSON.parse — any, он может быть связан с переменной, явно типизированной (как Article в данном примере). Не записывая any самостоятельно, мы указываем TypeScript игнорировать вероятность во время выполнения, когда разобранный контент не удовлетворяет типу Article.

Следует помнить, что any часто используется в файлах внешнего определения (и не похоже, что положение изменится), а значит, требуется еще большая осторожность.

unknown и утверждения

Если бы вместо any использовалось unknown, то приведенный выше фрагмент был бы невозможен. Пришлось бы писать явные assertions (утверждения), используя ключевое слово as:

const shouldBeAnArticle = JSON.parse(input) as Article;

С помощью этого синтаксиса мы явно указываем TypeScript на необходимость ослабить бдительность. Это все еще далеко от идеала, но уже лежит на поверхности!

Выражения для сужения типов

Вместо того чтобы полагаться на небезопасные утверждения типов, можно использовать выражения для сужения типов.

“Процесс уточнения типов до более конкретных типов, чем объявленные, называется сужением”, — говорится в документации TypeScript.

Например, оператор typeof (предоставляемый JavaScript) может определить тип объекта во время выполнения.

console.log(typeof 42);
// Ожидаемая запись в логе: "number"

При использовании в условной конструкции TypeScript способен сузить тип объекта.

if(typeof input === "string") {
    // ввод сужается до строкового типа
    submit(input.toLowerCase());
}

Это выражение позволяет TypeScript предсказать, что input может быть только строкой в этой области видимости.

В то время как утверждения заставляют TypeScript доверять разработчику, сужающие выражения выводят типы из логики выполнения.

Дискриминация

Хотя TypeScript может сужать типы с помощью многих других выражений, это имеет смысл только при уточнении либо типов union, либо примитивных типов. Я называю это “дискриминацией типов”.

type Fish = { swim: () => void };
type Bird = { fly: () => void };
 
function move(animal: Fish | Bird) {
  if ("swim" in animal) {
    // ввод сужается до типа Fish
    return animal.swim();
  }
  // ввод сужается до типа Bird
  return animal.fly();
}

В приведенном выше примере ключевое слово in  позволяет TypeScript дискриминировать тип объекта animal.

В случае с данными unkown дискриминация типов может оказаться пустой тратой времени:

if(typeof input !== "string") {
    // ввод все еще неизвестен
}

Это означает, что нельзя полагаться только на выражения для сужения типов для внешних данных. Нужен другой способ сузить тип: валидация данных.

Zod приходит на помощь

Zod — инструмент валидации схемы объекта. Это означает, что он может гарантировать во время выполнения валидность любого объекта с определенной схемой.

Объявление схемы

Первое, что нужно сделать в случае с Zod, — определить схему.

import * as z from "zod";

const userSchema = z.object({
    id: z.number(),
    name: z.string(),
    age: z.number().optional()
}).strict();

Вы могли ознакомиться с этим подходом ранее, если использовали другие инструменты валидации, такие как yup и joi. Zod предоставляет множество функций (например, object() и string()), и каждая из них возвращает схему Zod, которая может быть объединена в более крупную схему.

Каждый фрагмент схемы можно “дорабатывать” с помощью методов (например, .optional()), а также создавать очереди методов для получения сложных правил валидации.

Популярность Zod позволяет найти множество инструментов, которые помогут преобразовать существующие типы и интерфейсы в схемы Zod. Рекомендую также сайт transform.tools для быстрого преобразования JSON-файла в схему Zod.

Использование схемы

Схема предоставляет два способа проверки данных: метод .parse(), который может выдать ошибку, и метод .safeParse():

const result = userSchema.safeParse(input);
if (!result.success) {
  result.error;
} else {
  result.data; // тип данных выводится из userSchema
}

Разбор либо завершается неудачей, либо возвращает объект, соответствующий заданной схеме валидации. В этом случае объект наследует тип, выведенный из структуры схемы.

Вывод типа из схемы

Как правило, данные используются совместно в нескольких областях и контекстах. По этой причине мы обычно объявляем псевдоним типа один раз, а затем используем его везде, куда попадают данные. Zod предоставляет обобщенную функцию  z.infer<> для доступа к типу, выведенному из схемы.

type Article = z.infer<typeof articleSchema>;

Zod позволяет определять сложные правила валидации, например проверять длину строки: z.string().min(6).

Эти правила не имеют эквивалента в логике TypeScript и переводятся в ближайший тип, в данном случае string.

Практическое применение Zod

Посмотрим, где можно использовать Zod в TypeScript-проекте.

Парсинг ответа API

“Никогда не стоит доверять бэкенду”, — считает каждый фронтенд-разработчик.

Основным источником неизвестных/непредсказуемых данных является ответ API. Можете вручную валидировать данные, полученные из промиса fetch.

fetch(getArticle)
  .then((response) => response.json())
  .then((data) => {
    return articleSchema.parse(data);
  })
  .catch(console.error);

Рекомендую также использовать для этого специальную библиотеку, например Zodios (библиотека на основе axios).

Поскольку нельзя контролировать структуру входящих данных, может понадобиться сделать внешние данные более соответствующими соглашениям и логике проекта. Zod предоставляет method .transform(), чтобы выполнить это во время проверки.

Валидация данных формы

Еще один источник внешних данных — пользовательский ввод. Zod предоставляет встроенные утилиты для проверки строк. Конечно, вы можете реализовать собственные правила с помощью method .refine().

const myString = z.string().refine((val) => val.length <= 255, {
  message: "String can't be more than 255 characters",
});

При использовании React можно применять схемы Zod для валидации форм в react hook form.

А как насчет дискриминации типов?

Я начал использовать Zod с версии 1. В этой версии был метод  .check(), позволяющий применять схему в качестве защиты типа, которая использовалась в условии для дискриминации типов.

Из-за этой возможности было заманчиво пойти по пути “полной схемы”, используя Zod как для проверки, так и для дискриминации типов. Однако такой подход быстро оказался пустой тратой времени.

К счастью, этот метод был удален из следующей версии библиотеки. Теперь Zod сфокусирован на своем первоначальном предназначении — валидации внешних данных. Выражения для сужения типов оказываются достаточными для большинства ситуаций, связанных с дискриминацией типов.

Если вы чувствуете себя ограниченным нативными выражениями и синтаксисом операторов if/else, обратите внимание на ts-pattern.

Вывод

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

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Adrien Gautier: Zod: Why you’re using TypeScript wrong

Предыдущая статьяКак написать оператор Kubernetes?
Следующая статьяТекстовой эмбеддинг: классификация и семантический поиск