Сегодня мой последний день здесь, я конечно порядком подустал за неделю, но и все донести тоже не успел
Поэтому последним тредом тут будет опять typescript, но на этот раз про валидацию I/O, а точнее контракта бэкенда
Итак, вот у нас ест фронтенд который как-то получает данные от бэкенда
Если мы используем статическую типизацию то мы наверняка хотим типизировать данные приходящие к нам.
Если рассмотреть типичную ситуацию с Rest Api и Json то это будет выглядеть как-то так
type User = { id: number }
const getUser = (id: number) =>
fetch<User>(
/user/${id}
, 'GET')
.then(r => r.json())
const user = await getUser(1)Но здесь на самом деле происходит неявная конвертация из any в User! Потому что на самом деле нет никаких гарантий что бэк прислал именно такой жсон. Там может быть null, там может быть опечатка в поле, да что угодно, as long as it's valid
Но зачем мы тогда пишем типы, включаем strict если оно все равно может спокойно упасть в рантайме по совершенно банальной причине?
Давайте защищаться от такого.
Есть радикальные способы: смена способа общения на такую, которая гарантирует проверку данных: тот же protobuf скажем
Есть способы борьбы через кодогенерацию, Swagger или GraphQL (так как у вас есть инфа о типах, можно сгенерить и валидатор)
Для того чтобы доказать используются type refinements (они же type guards) Выглядеть может примерно так Можете поиграться сами typescriptlang.org/play/#code/MYe… pic.twitter.com/NUvgdPLZ0A
Но что же делать если на GraphQL и даже Swagger бэкендеры не соглашаются (например это внешний сервис), а проверять хочется?
Использовать голый unknown нормально не получится
twitter.com/jsunderhood/st…
Наивным подходом будет просто писать валидаторы руками. Взяли какой-нибудь github.com/ianstormtaylor… и написали
import { struct } from 'superstruct'
type User = { id: number }
const User = struct({
id: 'number',
})
const getUser = (id: number) =>
fetch<User>(
/user/${id}
, 'GET')
.then(r => r.json())
.then(json => User(json)) // валидация тутПроблема здесь я думаю налицо: как держать тип и валидатор в синхронизации? Любое изменение как валидатора, так и типа никак не проверить, все на вашей совести. Поэтому такой подход плохо скейлится на средних и больших проектах
Чтобы удобно синхронизировать их, нужно иметь single source of truth. Их потенциально тут два: либо тип, либо валидатор. Для начала рассмотрим валидатор.
Короче, можно написать валидатор и из него уже вывести тип, комбинируя валидаторы! Самый известный представитель этого подхода в ТС - github.com/gcanti/io-ts, можно еще вглянуть на github.com/pelotom/runtyp…
В целом отличный подход для старта свежего проекта, но не без недостатков
Использование вывода там на полную катушку и если у вас огромные конкракты и их много, можно перегрузить ТС (я слышал о случаях когда ВебШторм помирал с io-ts, но это вполне мог быть и ТС сервер)
Развесистая фигня, требующая времени чтобы понять что тут к чему
Непростая интеграция в существующий проект
Альтернативой будет взять тип и по нему сделать валидацию. Это вполне распространенный подход в ООП языках которые имеют рефлексию.
Но в ТС есть проблема: рефлексия в жс в принципе есть, можно читать поля объекта, можно делать typeof. Но типов то нет!
Точнее типы конечно есть, да не те. Мы то хотим сделать валидацию по type User = { id: number }, но при компиляции он просто стирается. И тут опять же два варианта развития событий
Вариант номер раз: отправить информацию о типах в рантайм. Это как раз и есть reflect-metadata о которых мы говорили в среду
Мощная фича, есть например вот такой github.com/typestack/clas…
Проблемы следующие
Декоратор должен быть повешен на поле реального объекта или класса, то есть вы не можете написать
type User = {
@Guid
id: string
}
Придется все писать на классах (а классы это ненужная в этом месте абстракция, лишний рантайм и проблемы и сериализацией/десериализацией)
Получается вот это
Я даже написал рабочую либу!
github.com/ts-type-makeup…
Вообще с помощью такого подхода можно делать еще много всяких штук, скажем генерить prop-types, делать фейковые бэкенды только по описанию эндпоинтов, генерить property-based tests
Но конечно с такой силой приходят и недостатки. Никакие из них не являются нерешаемыми (разве что один), но я начал заниматься этим только недавно, так что сделайте скидку :)
Пока это работает только через typescript transformer
Я написал доки как интегрировать это с ts-loader и ts-node
github.com/ts-type-makeup…
Но все равно это не также легко как и babel plugin
Хорошие новости в том, что принципиально это возможно
github.com/milesj/babel-p…
Пока нет поддержки кастомных валидаторов
А это crucial thing, чтобы можно было написать валидатор для UUID, ISO8601, целых чисел и подобной фигни
Планирую поддержать это через user type guards и Branded types
import _isUuid from 'is-uuid'
type Uuid = string & { readonly __brand: uniqe symbol }
const isUuid = (v): v is Uuid => _isUuid.v4(v)
type User = { id: Uuid }
validate<User>(jsonParsed, [isUuid])
Проблема связанная конкретно с тем что мы фактически имплементируем c++ templates
github.com/ts-type-makeup…
Для каждого разного аргумента дженерика мы генерируем новую версию функции
Но при этом при компиляции нам нужно найти эту функцию, прочитать дженерик и сгенерить нужную
Но если кто-то сделает
const myValidate = <T>(jsonStr: string) =>
validate<T>(JSON.parse(jsonStr));
То нам придется размножать функцию myValidate, а не validate. Но я не могу знать об этом заранее.
Соответственно я сначала должен найти вызовы моей функции. Если я нахожусь в теле другой функции дженерик которой передается в мою, мне нужно размножить и мою функцию и внешнюю. Или заинлайнить код
Но таких вложенных вызовов может быть много, что только усугубляет проблему
Я призываю компиляторщиков которые наставят меня на путь истинный и подскажут что курить и как с этим бороться
Вот такие пироги! Ставьте звездочки, а также обратите внимание что либа имеет полный набор честных e2e (если можно так назвать) тестов
github.com/ts-type-makeup…