Pull to refresh

Comments 15

Когда реальный ответ прилетает с сервера — никакой проверки не происходит. TypeScript просто верит вашей аннотации и молчит.

Ну нет же. В этот момент уже никакого TypeScript там нет. Он исчез во время компиляции.

Возможно не правильно сформулировал, я просто хотел подсветить здесь, что в данном моменте - типизация != гарантия прихода данных того типа что мы указали.

Typescript не врёт, а выполняет свою функцию. То что вы подумали о том, что язык отвечает за данные, то это никогда и нигде так не было. Всё что приходит снаружи подлежит или валидации, или доверию. Язык может лишь подсветить проблемы при правильном использовании, но ни в одном даже очень типизированном языке, как правило, не убирают возможно “небезопасно” скастовать тип. Так это не язык врёт, это программист его неправильно использует

Ой да, полностью согласна! Я раньше думала, что раз TypeScript показывает типы, то всё ок, а потом поймала баг из-за того, что сервер прислал совсем не то, что ожидалось. Теперь всегда делаю проверку через Zod реально экономит кучу нервов.

В обозримом будущем планирую выпустить новую библиотеку, которая немного с другой стороны подходит к валидации, чем Zod, Valibot и другие schema-first решения.

В ней TS типы запроса и ответа по эндпоинту остаются в текущем виде и являются полноценным источником правды:

import type { TypeUser } from '@models/TypeUser';

type TypeRequest = { id: string; };

type TypeResponse = TypeUser;

А библиотека из этого файла генерирует валидаторы, которые можно вызвать в рантайме:

export default {
  request: { id: 'string' },
  response: {
    id: 'number',
    name: 'string',
    email: 'string',
  },
} as const;

Соответственно, остается встроить проверки в функцию вызова апи

import schema from 'validatiors/api/getUser.ts'

async function getUser(request: TypeRequest): TypeResponse {
  // удаляет все лишнее
  const reqErrors = check({ 
    schema: schema.request, 
    value: request, 
    getExtraneous: console.log 
  });
  
  if (reqErrors) throw new Error(reqErrors);
  
  const response = await fetch('/api/user').json();
  
  // удаляет все лишнее
  const resErrors = check({ 
    schema: schema.response, 
    value: response, 
    getExtraneous: console.log 
  });
  
  if (resErrors) throw new Error(resErrors);
  
  // TypeScript верит вам на слово,
  // но валидаторы не пропустят расхождений по типам и структуре данных
  return response;
};

Фронт ясно говорит, что ему нужны только 3 поля в TypeUser, поэтому check очистит легаси, приходящее с бэка, специфику для других платформ, дубликаты с разным неймингом.

getUser вернет 100% соответствующую типам структуру и залогирует в консоль все расхождения в ожиданиях, а при критичных - вернет список понятных ошибок.

Поддерживать TS-типы как источник правды намного проще (они все равно нужны в приложении), чем описывать схемы в Zod-стиле.

Поддерживать TS-типы как источник правды намного проще (они все равно нужны в приложении), чем описывать схемы в Zod-стиле.

Но схемы умеют делать дополнительные проверки, например по регексу.

А вообще, имхо, источником правды должен быть сваггер/openApi, из которого на фронте генерятся типы и (при необходимости) валидаторы.

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

Но на фронте задача другая - если использовать TS, то важна именно совместимость по типам - что пришла строка, а не число (и неважно - это email или isoDate), так обеспечивается работоспособность рантайма. А проверки на email в основном нужны бэку при сохранении данных (ну и для форм если надо - можно zod или аналог прикрутить).

При этом намного удобнее не полностью Swagger Open API переносить в проект, а детально заводить типы - если фронту в TypeUser нужно только 3 поля, а не 800 присылаемых бэком с relationships, ios / android / tv спецификой, то лучше так и завести. Это же потом и стабильность улучшит - не будет ломаться при изменении неиспользуемых полей или ошибок в них, фронтендер всегда может сказать "мы используем 3 поля, проверьте не устарела ли выдача из 800 полей", трафика меньше, тестировать проще, в сторы фронта не протекает лишнего и т.п. Думаю любой найдет много причин держать код и рантайм чистыми, а не "тащить все что дают".

Продолжу мысль, с Вашего позволения: … а сваггер/openAPI схемы должны генериться из TypeScript типов :) Логично же?

По разному бывает. На моей прошлой работе, например, схему (в дополнение к ТЗ) составляли аналитики, натурально записывая файлы вручную.

Оно у вас будет работать с типами вида user: { gender: 'F', childrenCount: number } | { gender: 'M', salary: number }?

Да, там делаю очень замороченный TS AST парсер с поддержкой всего, что может быть в Open API, включая реэкспорты, алиасы, построение инверсированных зависимостей (дерева импортов). Конкретно для этого кейса будет такой валидатор

user: [
  'union',
  { gender: ['literal', 'F'], childrenCount: 'number' },
  { gender: ['literal', 'M'], salary: 'number' }
]

И если бэк пришлет { gender: 'M', salary: '100' } то будет ошибка response.user is none of 2 types. Для дебага должно быть достаточно.

Ну и в любом случае это лучше, чем ловить где-то при открытии модалки undefined has no method toFixed in user.salary.toFixed()

В угоду обратной совместимости, многие вещи в тайпскрипте возвращают any, вместо нормального типа. Если бы Response.json() возвращал unknown, то подобной проблемы бы не возникло и программисту пришлось бы разбираться в чём дело(конечно, многие бы просто забили и скастили бы тип к нужному, но всё же).

Есть маленькие библиотеки, которые исправляют эти косяки, например ts-reset, с ними жить становится чуть безопаснее.

Zod позволяет описать схему один раз — и получить сразу и тип, и рантайм-валидацию:

import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

type User = z.infer<typeof UserSchema>; // тип выводится автоматически

const getUser = async (): Promise<User> => {
  const res = await fetch('/api/user');
  const data = await res.json();
  return UserSchema.parse(data); //  если структура не та — бросит ошибку
};

Лично для меня этот подход крайне спорный, и я его не использую из следующих соображений:

  • Данные пришедшие с сервера по определению валидны, так как они проходят валидацию при сохранении. Нам не нужно проверять что поле email это правда емэйл. Да – правда. Его бы не занесли в базу будь он не валидным.

  • Проверять нужно только соответствие контракту: ждали строку – пришла строка, ждали свойство userName – пришло userName, а не user_name.

  • Можно валидировать данные перед отправкой, и для этого я использую Constraint Validationmin, max, pattern и пр.

  • Мне не нужны какие-то непонятные схемы, мне нужна конкретная модель сущнсти, и я предпочитаю rich models.

И для этого я написал себе библиотеку, которая решает мои задачи. То что вы называете схемой, у меня – модель:

import { Transformer } from 'kr-transformer';

class UserModel {
  // Тип – строка
  name = '';

  // Тип – число
  age = 0;

  // тип – булево
  student = false; 

  
  setName() { 
    // ...какой-то метод
  }

  get someComputation() {
    return this.age * 3;
  }
}

const json1 = { name: 'John', age: 42, student: true };
const json2 = { name: 'John', age: "42", student: true };
const json3 = { name: 'John', age: 42, student: null };
const json4 = { username: 'John', age: 42, student: false };


try {
  // валидный JSON, ошибок нет
  const model = Transformer.fromJSON(json1, UserModel); 
} catch(error) {
  console.log(error) // 
}


try {
  // age строка – невалидно
  const model = Transformer.fromJSON(json2, UserModel); 
} catch(error) {
  console.log(error) // Unexpected type of <age>, expect Number but receive String
}

try {
  // student null – невалидно
  const model = Transformer.fromJSON(json3, UserModel); 
} catch(error) {
  console.log(error) // ...ошибка
}


try {
  // username вместо name – невалидно
  const model = Transformer.fromJSON(json4, UserModel); 
} catch(error) {
  console.log(error) // ...ошибка
}

То есть, полученный с сервера невалидный JSON, просто превратится в модель с понятной ошибкой. Модель сама по себе тип. С моделью дальше гораздо удобнее работать чем с каким-то json-ом.

Важное напоминание, спасибо за статью.

При актуальности проблемы я бы посоветовал разработчикам завести себе BFF, там можно сконцентрировать эти проверки и код для них предотвратив размазывание по проекту.

Sign up to leave a comment.

Articles