🔥

Тред (Майк Башуров)


Сегодня мой последний день здесь, я конечно порядком подустал за неделю, но и все донести тоже не успел Поэтому последним тредом тут будет опять 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 } Придется все писать на классах (а классы это ненужная в этом месте абстракция, лишний рантайм и проблемы и сериализацией/десериализацией)

Получается вот это
notion image

Я даже написал рабочую либу! 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…