TypeScript сказал, что всё нормально. Production с этим не согласился
Во вторник утром backend выкатили с небольшим изменением. Ничего драматичного: одно поле в ответе API теперь приходило строкой.
Было:
//json
{
"id": 42,
"name": "Alex"
}Стало:
//json
{
"id": "42",
"name": "Alex"
}HTTP-запрос по-прежнему возвращал 200. JSON был валидным. Backend работал.
Frontend тоже успешно собирался.
TypeScript не нашёл ни одной ошибки.
А потом в production кто-то открыл страницу пользователя и получил:
//text
TypeError: user.id.toFixed is not a functionПричина оказалась в коде, который выглядел совершенно нормально:
//ts
type User = {
id: number;
name: string;
};
const response = await fetch('/api/user/42');
const user = await response.json() as User;
console.log(user.id.toFixed(0));Вот здесь и находится довольно неприятная особенность TypeScript.
Компилятор проверил тип User. Но никто не проверил, что сервер действительно прислал User.
В коде всё было правильно. И всё равно сломалось
На первый взгляд трудно предъявить претензии этому коду.
Тип объявлен.
API вызван.
Редактор знает, что такое User.
user.id определяется как number.
toFixed() существует у number.
Никаких красных подчёркиваний.
Но response.json() приносит обычные данные, полученные во время выполнения. TypeScript не может заранее знать, что окажется в этом JSON.
А конструкция:
//ts
as Userне запускает никакую проверку.
Документация TypeScript прямо говорит, что type assertion не выполняет runtime-проверку и не меняет данные; она только сообщает компилятору, какой тип следует считать у значения.
Поэтому эта строчка:
//ts
const user = await response.json() as User;по смыслу гораздо ближе к:
//text
«Я думаю, что это User. Поверь мне».Чем к:
//text
«Проверь, что это User».И это две совершенно разные вещи.
Самая неприятная часть - ошибка может появиться далеко от API
Допустим, мы сразу получили неправильный ответ.
Но программа не обязательно упадёт там же.
Например:
//ts
const response = await fetch('/api/user/42');
const user = await response.json() as User;
saveUserToStore(user);Потом:
//ts
const user = getUserFromStore();
renderUser(user);А ещё позже:
//ts
function renderUser(user: User) {
return `${user.name}: ${user.id.toFixed(0)}`;
}И вот здесь уже происходит падение.
Человек, открывающий stack trace, может вообще не искать проблему в API.
Он будет смотреть на renderUser() и удивляться, каким образом id внезапно оказался строкой.
Хотя ошибка появилась несколькими слоями раньше.
Это одна из причин, почему проверять данные лучше именно на границе приложения.
Можно проверить всё вручную
Для одной небольшой модели это не проблема.
Например:
//ts
type User = {
id: number;
name: string;
};
function isUser(value: unknown): value is User {
if (typeof value !== 'object' || value === null) {
return false;
}
const data = value as Record<string, unknown>;
return (
typeof data.id === 'number' &&
typeof data.name === 'string'
);
}Теперь:
//ts
const data: unknown = await response.json();
if (!isUser(data)) {
throw new Error('API returned invalid user');
}
console.log(data.id.toFixed(0));Вот здесь TypeScript уже действительно помогает.
Мы не объявили данные User заранее.
Сначала получили unknown.
Потом проверили структуру.
И только после этого начали использовать значение как User.
Проблема в другом: представьте, что таких моделей не одна, а пятьдесят.
Тогда ручные проверки быстро превращаются в отдельный слой кода, который тоже приходится поддерживать.
Здесь Zod оказывается к месту
Zod как раз предназначен для runtime-валидации структур данных и умеет выводить из схемы статические TypeScript-типы. В актуальной документации Zod 4 схема создаётся один раз, .parse() проверяет входные данные, а z.infer позволяет получить TypeScript-тип из той же схемы.
Например:
//ts
import * as z from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
});Теперь запрос можно обработать так:
//ts
const response = await fetch('/api/user/42');
const data = await response.json();
const user = UserSchema.parse(data);Если пришло:
//json
{
"id": 42,
"name": "Alex"
}объект проходит проверку.
Если:
//json
{
"id": "42",
"name": "Alex"
}валидация завершится ошибкой.
Причём ошибка возникает в том месте, где проблема появилась, а не спустя несколько вызовов функций.
Для сценариев, где исключение при невалидных данных нежелательно, у Zod есть safeParse(), который возвращает результат с success, data или ошибкой.
Например:
//ts
const result = UserSchema.safeParse(data);
if (!result.success) {
console.error(result.error);
throw new Error('Invalid API response');
}
const user = result.data;Теперь в приложение не проходит произвольный JSON.
И вот тут TypeScript снова становится полезным
Нам всё равно нужен TypeScript.
Просто теперь не приходится отдельно поддерживать две модели:
//ts
type User = {
id: number;
name: string;
};и:
//ts
const UserSchema = z.object({
id: z.number(),
name: z.string(),
});
Тип можно получить прямо из схемы:
//ts
type User = z.infer<typeof UserSchema>;Zod официально поддерживает такой вывод типов.
Получается довольно удобная связка:
//ts
const UserSchema = z.object({
id: z.number(),
name: z.string(),
});
type User = z.infer<typeof UserSchema>;Дальше схема работает во время выполнения:
//ts
const user = UserSchema.parse(data);А тип - во время разработки:
//ts
function formatUser(user: User) {
return `${user.name}: ${user.id}`;
}То есть инструменты не делают одну и ту же работу.
Но разве backend не должен соблюдать контракт?
Должен.
Если у компании есть OpenAPI, code generation, контрактные тесты и нормальная CI-проверка, ситуация намного лучше.
Но frontend всё равно не должен считать сеть доверенной только потому, что backend написан той же командой.
Контракт может поменяться.
Может появиться старый клиент.
Может прийти ответ от стороннего сервиса.
Может ошибиться промежуточный сервис.
Может произойти неудачная миграция.
Может быть просто баг.
И даже если архитектура идеальна, вопрос остаётся тем же:
кто проверяет фактические данные во время выполнения?
TypeScript этого не делает.
Он и не обещает этого делать.
TypeScript является статическим анализатором типов, а типы после компиляции не остаются в JavaScript как runtime-проверки.
В этом месте часто появляется !
Есть ещё одна конструкция, которую любят использовать рядом с as:
//ts
const raw = localStorage.getItem('user')!;Так TypeScript перестаёт требовать обработку null.
А дальше:
//ts
const user = JSON.parse(raw) as User;На первый взгляд всё красиво.
На деле программа только что получила неизвестную строку, сказала «там точно есть значение» и затем сказала «это точно User».
Ни одна из этих конструкций ничего не проверила.
Документация TypeScript отдельно предупреждает: non-null assertion ! убирает null и undefined только на уровне типов и не производит runtime-проверку.
Поэтому конструкция вроде:
//ts
JSON.parse(localStorage.getItem('user')!) as Userможет быть маленькой, но ответственность на ней огромная.
Не нужно тащить Zod в каждую функцию
Есть соблазн после таких примеров начать валидировать абсолютно всё.
Это тоже ошибка.
Например:
//ts
function calculateTotal(
price: number,
quantity: number
) {
return price * quantity;
}Если эта функция вызывается внутри уже типизированного приложения, TypeScript вполне справляется со своей задачей.
Нет смысла запускать Zod перед каждым арифметическим выражением.
А вот такие места уже интереснее:
//ts
fetch()//ts
request.json()//ts
localStorage.getItem()//ts
webhook//ts
message.dataЭто точки, где в программу входят данные, которые TypeScript сам не контролирует.
Именно там я бы ставил runtime-валидацию.
Ещё один момент, который часто путают
Zod не делает данные «правильными».
Он проверяет их.
Это важно.
Допустим, API прислал:
//json
{
"id": 42,
"name": ""
}Схема:
//ts
const UserSchema = z.object({
id: z.number(),
name: z.string(),
});пустую строку примет.
И будет права: с точки зрения этой схемы данные корректны.
Если имя обязательно должно содержать хотя бы два символа, это нужно выразить в самой схеме:
//ts
const UserSchema = z.object({
id: z.number(),
name: z.string().min(2),
});А если API допускает null, схема тоже должна это отражать.
То есть runtime-валидация не заменяет проектирование контракта.
Она заставляет этот контракт сделать явным.
Что на самом деле произошло в нашем production-баге
Мы начали с:
//ts
const user = await response.json() as User;И предполагали:
//text
API → UserНа деле происходило:
//text
API → неизвестные данные → «считай это User»Вот этот промежуточный шаг и был проблемой.
Исправленная версия выглядит уже иначе:
//ts
const response = await fetch('/api/user/42');
const data = await response.json();
const user = UserSchema.parse(data);Теперь путь понятен:
//text
API → неизвестные данные → проверка → UserИ только после этого начинается работа бизнес-логики.
Поэтому я бы не говорил, что TypeScript «не защищает»
Это было бы несправедливо к самому TypeScript.
Он сделал именно то, для чего предназначен.
Он проверил:
//ts
user.id.toFixed(0)и увидел:
//ts
id: numberПроблема в том, что разработчик сам сообщил ему эту информацию через:
//ts
as UserTypeScript не может проверить содержимое удалённого сервера во время компиляции.
И не должен.
Его задача - помочь нам безопаснее работать с кодом.
Для внешних данных нужен следующий слой.
А значит, главный вопрос не «нужен ли Zod»
Мне кажется, полезнее задавать другой вопрос:
где в приложении заканчиваются наши гарантии?
Пока значение создаётся внутри строго типизированного кода, TypeScript даёт нам много полезных гарантий.
Как только значение приходит из API, webhook, localStorage, файла или стороннего сервиса - это уже просто данные.
Их можно описать типом сколько угодно раз.
От этого они не станут проверенными.
Поэтому as User после response.json() - не валидация.
Это всего лишь доверие.
А если данные действительно важны для работы приложения, доверять им на слово бывает слишком дорого.
В нашем случае одна маленькая перемена на backend превратила:
//json
"id": 42в:
//json
"id": "42"TypeScript ничего не заметил.
И, пожалуй, именно этого стоит помнить, когда в следующий раз рука сама потянется написать:
//ts
as UserСначала спросите себя: я действительно проверил данные или просто попросил TypeScript мне поверить?
Комментарии
Чтобы оставить комментарий, войдите в аккаунт.