Как стать автором
Обновить

Комментарии 44

Пользуясь случаем оставлю ссылку на свою библиотеку которая делает очень похожие вещи, но в сугубо фунцкиональном стиле https://github.com/venil7/json-decoder

Да, напоминает Either из Haskell, мне нравятся такие подходы

ну я вдохновлялся декодером из Elm, а там и до Хаскеля не далеко

Elm гарантирует, что код написанный на нём не упадёт. Вам нужно немного больше тестов написать:


const { objectDecoder, stringDecoder, numberDecoder } = require("json-decoder")

const petDecoder = objectDecoder({
  name: stringDecoder,
  age: numberDecoder,
});

const result = petDecoder.decode(null)
// TypeError: Cannot read property 'name' of null

Я вам пулл реквест скинул, гляньте, может быть, что-то вам придётся по душе.


У вас приятно смотреть на код)

спасибо, напишу в личку

Ну и я со своей торбой.


Отличительная особенность:


  • выведение типов из схемы
  • позволяет не только валидировать, но и нормализовывать ответ

Пример из статьи выглядит так:


import {
    $mol_data_record as Rec,
    $mol_data_string as Str,
    $mol_data_enum as Enum,
    $mol_data_integer as Int,
    $mol_data_dictionary as Dict,
    $mol_data_pattern as Pattern,
} from 'mol_data_all'

enum Sex {
    'male' = 'male',
    'female' = 'female',
}

const Phone = Pattern( /^\d{7,15}$/ )

const User = Rec({
    id: Str,
    name: Str,
    gender: Enum( Sex , 'Sex' ),
    age: Int,
    phoneBook: Dict( Str , Phone ),
})

А использование, соответственно:


function printUser( user : typeof User.Value ) {
    console.log( user )
}

const user = User( json )
printUser( user )

Люблю все решения с автоматическим выводом

Интересно узнать с какой целью вы дали именно такие жесткие имена на выходе своей библиотеки.


Причем очевидно вы сами знаете, что они не удобные — потому что в этом же примере их переименовываете.


В этом есть какой-то скрытый смысл?

В МАМ экосистеме этот код выглядел бы так:


enum $my_sex {
    'male' = 'male',
    'female' = 'female',
}

const $my_phone = $mol_data_pattern( /^\d{7,15}$/ )

const User = $mol_data_record({
    id: $mol_data_string,
    name: $mol_data_string,
    gender: $mol_data_enum( Sex , 'Sex' ),
    age: $mol_data_integer,
    phoneBook: $mol_data_dictionary( $mol_data_string , $my_phone ),
})

Никаких импортов и переименовываний. Но в NPM приняты импорты и короткие имена, поэтому в том примере показан более традиционный путь. Почему переименовывание не вынесено внутрь npm модуля:


  1. npm модуль генерится автоматически, а короткие имена надо задавать вручную. Ну я не заморачивался.
  2. Там лежит не только модули из $mol_data, но и, например, из $mol_fail, $mol_diff и $mol_error. Когда начнём переезд в другой неймспейс, там появятся модули и не из $mol.
НЛО прилетело и опубликовало эту надпись здесь

Речь о загрузке схемы из файла? Боюсь, что тут такое не поддеживается. Для этого нужно будет делать DSL на JSON, что выглядит достаточно стрёмно.

В идеале дополнительного синтаксиса быть не должно вообще.

interface User {
  id: string;
  name: string;
  gender: "male" | "female";
  age: number;
  phoneBook: {
    [name: string]: string;
  };
}


В идеале, этого должно быть достаточно. При помощи рефлект-метадаты получаем эту структуру и на её базе — валидируем JSON
Тогда, для начала, в TypeScript должны появиться интерфейсы в рантайме

Но мы вполне можем использовать классы для этого. Довольно допустимо, если будет class User, а не interface User.

Ну class-validator вроде на классах

Хотя на мой взгляд всё же многословно, и не так быстро работает

Да, мы тоже используем для де-/сериализации и валидации декораторы и обычные классы:


import { Json, Rule } from 'class-json';

class Address {
    @Rule.validate(FooCustomValidator)
    @Json.type(String)
    line: string
}
class User {
    @Rule.minimum(1)
    @Json.type(Number)
    id: number

    @Rule.required()
    @Json.type(Date)
    date: Date

    @Rule.pattern(/^\w+$/)
    @Rule.required()
    @Json.type(String)
    name: string

    @Rule.type(Address)
    address: Address
}

Классная штука(имею в виду использование декораторов + классы), и подобная идея используется во многих языках (c#, Go).


Но мне пока не сильно по душе использовать в своей библиотеке не стандартизированные декораторы.


const addressSchema = {
    line: v.and(v.string, v.custom(addressLineValidator)),
}
const checkUser = v<User>({
  id: [undefined, v.and(v.number, v.min(1))],
  date: v.custom(x => x instanceof Date),
  name: v.and(v.string, v.test(/^\w+$/)),
  address: [undefined, addressSchema],
})

Да, создано с оглядкой на Json.Net. По декораторам: мы не боимся — рано или поздно они войдут в стандарт. И даже если спека немного подправится, то тут тоже всё в норме — на коленке за пару минут можно будет миграционную утилиту написать. Из этого следует — декораторами можно уже наслаждаться. Тут вся прелесть в том, что модель и мета данные к ней — в одном месте, плюс работает наследование.


А то что вы в примере написали, это только валидация, или ещё десериализация? То-есть, например, временные строки будут преобразованы в Date?

Только валидация

Подумал насчёт этого.


Вполне реально не добавляя рефлексию в тайпскрипт — внести туда подобную штуку:


Например можно внести в стандарт языка что-то наподобие такого оператора:


if (value is valid User) {
 // value with type User
}

Что под капотом создаст функцию


function __validUser(value): value is User {
  if (value == null) return false
  if (typeof value.name !== 'string') return false
  if (typeof value.gender !== 'male')
  // ...

И в результирующем коде будет


if (__isValidUser(value)) {
  // ...
}

Но такой оператор — не введут никогда — потому что это нарушит то, что TypeScript — в большинстве случаев — это валидный js, если убрать описание типов.

Я в исследовательских целях делал генератор на базе AST для конвертирования JSON в инстансы классов и наоборот. Рантайм оверхед — 0. Работу вёл в рамках proof of concept, до рабочей версии довести это пока нет времени. Пользоваться можно было бы как-то так (но с дополнительным степом вызова CLI, чтобы среда разработки, линтеры и люди тоже подхватили новые функции):

// src/models.ts
import { Transform, Expose } from 'mycooljsonlib@localhost';

// Декораторы удаляются из скомпилированного кода, если использовать transformer, 
// поставляемый с либой. Подключение, правда, костылями, через ttypescript,
// потому что MS не желают (пока?) адекватно поддерживать 
// плагины в дефолтном конфиге TypeScript
// Декораторы сами по себе - просто маркеры для трансформера, они не меняют
// поведение классов и полей
@Transform()
export class PhoneNumber {
  name: string;
  countryCode: string;
  phone: string;
}

@Transform()
export class User {
  id: string;
  name: string;
  gender: "male" | "female";
  // Опциональные поля тоже допустимы
  age?: number;

  // Возможность переименовывать поля
  @Expose('phone_book')
  // В оригинале был объект, но я до этого не дополз, массив работал
  phoneBook: Array<PhoneNumber>;
}

// index.ts

// Я делал предположение, что лучше разрешить конвертировать любой объект,
// чем изначально завязываться на JSON строку
const plainObject = JSON.parse('<...input...>');

import { plainToUser } from './generated/models';

// В дальнейшем можно было бы добавить функции jsonToUser()
// На выходе получаем инстанс класса юзер
const user = plainToUser(plainObject);

// При обратной конвертации - объект
const plainAgain = userToPlain(user);

// generated/models.ts

export function plainToUser(plain): User {
  const obj = new User();

  if (typeof plain['id'] === 'string') {
    obj.id = plain['id'];
  } else {
    throw new Error('Invalid type for id: expected string, got <...>');
  }

  if (isArray(plain['phone_number'])) {
    obj.phoneNumber = plain['phone_number'].map(plainToPhoneNumber);
  } else {
    throw new Error('...');
  }

  // тут остальные проверки для оставшихся свойств

  return obj;
}

Код plainToUser похож, на то, что производит quartet.


Мне не сильно по душе классы — я больше сторонник clojure подхода к данным. Но это всё вкусовщина.


Но идея кодогенерации вместо рантайм кодогенерации — очень хорошая.


А я вот подумываю сделать что-то наподобие .toMatchInlineSnapshot() в jest.


Чтобы можно было написать вот так:


interface User { ... }

const checkUser = v<User>(/* generate */)

и утилита бы заменила / generate / на нужную схему для интерфейса User


Таким образом рантайм генерация функций осталась бы, но тратить силы на создание схемы не пришлось бы.


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

Мне не сильно по душе классы — я больше сторонник clojure подхода к данным. Но это всё вкусовщина.

Я сейчас много пишу на Реакте и тоже привык к функциональному подходу и иммутабельными данными и трансформациями (как я раньше без этого жил?). У классов в моём случае преимущество состоит в том, что можно применять декораторы к полям. Я планировал добавить поддержку кастомных трансформеров.

В выдуманном примере может быть так:
@Transform()
class DataObject {
  @Expose({ 
    toClassField: (value) => value.split(';'), 
    fromClassField: (value) => value.join(';') 
  })
  dataField: Array<number>;
}

const input = JSON.parse('{ "dataField": "1;2;3;4" }');

const obj = plainToDataObject(input);

// isArray(obj.dataField) === true

Надеюсь декораторы стандартизируют и точно можно будет делать много чего отрадного, не опасаясь, что их потом поменяют — и решение с декораторами — придёт в негодность.

Сам писал нечто похожее с классами и декораторами, как и большинство комментаторов. Но ушел от этого.


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

Мне сложно понять как можно быть огражденным от изменений моделей АПИ — если АПИ меняется, ваш маппер должен поменятся, разве нет?


Понятно что в таком случае весь остальной код вашего фронта не изменится — но маппер же должен поменятся.

Да, конечно, маппер меняется. Но я под маппером подразумеваю функцию (две функции), которая тип А преобразует в тип Б. Но это вполне себе ограждение.

А факт того, что АПИ вам присылает тип А вы считаете аксиомой, или как-то проверяете?


Просто если аксиомой — то вам не нужна валидация в принципе.


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

тип А — это что-то вроде Partial of ApiModel (в идеале unknown, но решаете по мере необходимости). И внутри маппера вы проводите преобразования, дефолтные значения подставляете (можете и ошибки бросать, если надо).
Вот схема из одного проекта, как у меня данные преобразуются и где ошибки ловятся:

DirtyApiResponse -> CandyParams -> CandyFactory -> Candy.

1) DirtyApiResponse -> CandyParams — это преобразование делает маппер (о котором я выше говорю): могут меняться названия полей, вложенность, простые преобразования строки в дату и т.п. Маппер гарантирует, что на выходе получится CandyParams.

2) CandyParams -> CandyFactory — хоть параметры и соответствуют интерфейсу, но внутри могут оказаться логические несостыковки. Эту валидацию более высокого уровня проводит CandyFactory.

Это не эталонная схема, просто пример. Но как видно, библиотеки типа quartet, наивно полагают, что они могут сразу преобразовать DirtyApiResponse в Candy, проведя все валидации. В результате, вы напрямую зависите от DirtyApiResponse, и подстраиваете свою модель под него. И наоборот, использование библиотеки для преобразования DirtyApiResponse в CandyParams часто является оверхедом. Вот таким образом я и пришёл к тому, что маплю всё руками. Немного рутины, но зато видно все соответствия/несоответствия на границе фронт/апи.

Вы немного не поняли — quartet не превращает данные. Он гарантирует, что они такой формы, какую вы ожидаете, и к которым можете применить Mapper, CandyParams или CandyFactory.

Тогда перефразирую)

некоторые разработчики, наивно полагают, что они могут сразу `function isCandy(value: DirtyApiResponse): value is Candy` и погнали

У меня к этому такой подход: пока то, что приходит с API меня устраивает и может использоваться в неизменном виде — я пишу такую функцию(либо руками либо quartet)


Если же нет — я пишу валидацию того, что мне обязательно нужно чтобы приходило с АПИ(это те случаи где вы бросаете ошибку)
И потом по проверенным данным запускаю алгоритм превращения этих данных в мою модель. (без бросаний ошибок)


Но процесс первоначальной проверки — присутствует всегда.


Если АПИ поменялось — есть два варианта, и выбирать любой можно исходя из плюсов и недостатков:
1) Поменять всю свою модель — иногда, когда логика сильно меняется — это может быть оправдано
2) Поменять функцию валидации, и написать маппер из новой АПИ в старую

Мне библиотека понравилась. Я воспользуюсь, если будет нужно. Это не критика. Это уточнения, мысли вслух. Воспоминания про свои такие библиотеки.

Я тоже не критикую ваш подход. Хотелось понять вашу точку зрения — поэтому и уточняю.

Совершенно правильно, что на границе с АПИ должен быть слой дающий на выходе — гарантированные структуру данных и её содержание.

Мне не понятно от чего вы ушли — если всё равно делаете проверки и бросаете ошибки, если надо.
Или вы имели в виду, что ушли от подходов с классами и декораторами и заменили их подходами с использованием череды if конструкций, где в одном случае — вы смиряетесь с тем, что данных нет, и заменяете их на валидные дефолтные значения, а в других случаях бросаете ошибку — когда данные не валидны?

ну… примерно так. Я перестал возлагать много надежд на автоматизацию этих преобразований.

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

Понятно, что такой слой должен быть, иначе беда и неконтроллируемые состояния

Оказиваеться, можно делить приложение на слои, которые имеют свои модели: UserDto, User и т.д. И "мапперы" тут тоже никто не отменял.

А за что вы невзлюбили подсветку синтаксиса?
const probablyUser: unkown = { ... }
if (checkUser(probablyUser)) {
    // probablyUser has type User
    console.log(probablyUser.name)
} else {
    // probablyUser has type unkown
    throw new Error('Probably User has not type User')
}

Код выше выглядит намного более читаемее, чем код ниже
const probablyUser: unkown = { ... }
if (checkUser(probablyUser)) {
    // probablyUser has type User
    console.log(probablyUser.name)
} else {
    // probablyUser has type unkown
    throw new Error('Probably User has not type User')
}

Воспользовался синтаксисом из гитхаба, с тройной обратной кавычкой и названием языка.


Но не прокатило, надо поправить

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории