Внедрение языков со статической типизацией для программирования на стороне клиента, таких как TypeScript, значительно повысило качество создания больших приложений на стороне клиента, что обычно наблюдается при создании современного SPA.

Это повышение безопасности распространяется только на границы вызовов API. Результаты удаленного вызова всегда возвращаются как неструктурированный JSON или any в TypeScript. Таким образом, десериализация выполняется вручную, что является утомительным и подверженным ошибкам процессом. Более того, процедуры десериализации страдают от различных форм битового гниения: по мере развития базовой модели ее валидация может устареть или, что еще хуже, немного перестать работать. Это приводит к ложным отрицательным результатам (данные верны, но десериализатор считает, что это ошибка) или ложным срабатываниям (неправильные данные просто приводятся к правильному типу модели, вызывая хаос в остальной части приложение).

Короче говоря, десериализация на стороне клиента - это, мягко говоря, не очень весело.

Постоянно растущее внедрение расширенных протоколов, таких как OData (или его недавний хипстерский вариант GraphQL), делает запросы со стороны клиента намного более эффективными: клиент может уменьшить количество получаемых атрибутов ($select), строк ($filter) и даже общее количество запросов по объединенным данным ($expand). К сожалению, это делает полученные данные еще более сложными для анализа, добавляя динамическое измерение к проблеме, что действительно не помогает.

В этой статье мы изучим расширенные механизмы типизации TypeScript, которые позволяют автоматически генерировать безопасный по типу десериализатор на основе декларативного описания запрашиваемых данных, созданных как объект.

Продвинутые типы

TypeScript поддерживает очень богатый язык типов, который позволяет создавать типы во время компиляции. Результирующие типы создаются путем сборки двух (или более) существующих типов с использованием операторов, подобных сумме и произведению. Этот процесс является абстрактным на уровне типов эквивалентом создания чисел из других чисел (5+3 действительно можно рассматривать как способ создания числа 8 путем составления двух чисел: 5 и 3).

Создать новый тип из двух существующих типов можно с помощью операторов | и &. a & b создаст тип со всеми полями a и всеми полями b. a | b создаст полиморфный тип, который будет содержать либо поля a, либо поля b. Так, например:

{ name:string, surname:string } & { age:number }

совпадает с типом:

{ name:string, surname:string, age:number }

в то время как:

type Car = { kind:"electric", num_batteries:number }
         | { kind:"petrol", engine_size:number }

Примет значения, полностью соответствующие любой из двух форм, и, таким образом, никогда не потерпит petrol машину с num_batteries.

Кроме того, TypeScript также поддерживает более специальные типы: keyof t - это тип всех имен полей типа t. Таким образом:

keyof { kind:"petrol", engine_size:number }

будет:

"kind" | "engine_size"

Последний, специальный тип, который поддерживает TypeScript, - это t[k], при условии, что k extends keyof t. t[k] - это тип поля k внутри типа t. Например:

{ id:number, name:string, surname:string, age:number }["age"]

будет просто string.

В TypeScript есть и другие соответствующие типы и операторы типов, но для целей этой статьи нам не нужны все они.

Интерфейс запроса

Используя эти расширенные типы, мы можем создать простой интерфейс определения запроса. В ограниченном объеме этой статьи мы будем поддерживать только язык определения запросов, который позволяет нам только указать, какие поля мы хотим получить из данного источника данных, а также некоторую базовую фильтрацию. Управление ссылками и объединениями также может быть добавлено как относительно простое расширение к основе, которую мы настраиваем в этой статье, но мы не увидим его здесь.

Класс запроса будет общим для двух типов: тип источника данных и тип данных, которые мы ожидаем в результате выполнения запроса. Определение запроса также будет содержать deserialize функцию, которая превращает неструктурированный any в result:

export class Query<result,source> {
  deserialize : (res:any) => result
  ...
  constructor(deserialize : (res:any) => result) {
    this.deserialize = deserialize
  }
}

Отправная точка для любого запроса может быть легко инкапсулирована вспомогательной функцией, которая предполагает, что любая запрашиваемая сущность будет иметь, как минимум, поле id:

export let entity_query = <t extends { id:number }>() =>
  new Query<{ id:t["id"] }, t>(
    res => "id" in res && typeof res["id"] === "number" ?
        ({ id:res["id"] as number })
      : fail(`Error: expected id in ${JSON.stringify(res)}`)
  )

Обратите внимание, что entity_query требует одного универсального аргумента, t, который, как ожидается, будет расширяться от { id:number } (это означает, что t должно иметь поле id типа number, а не t обязательно должно явно наследовать расширенный тип). После этого сгенерированный запрос выдаст результат типа { id:t[“id”] }. Десериализация в этом случае просто проверит, есть ли у полученного большого двоичного объекта ожидаемое поле id типа number. Если это так, то мы можем создать требуемый объект, в противном случае мы должны потерпеть неудачу с исключением (или альтернативой типобезопасности, такой как Option).

Давайте добавим несколько методов в наш Query класс. Поскольку мы хотим, чтобы наши запросы поддерживали поиск полей из удаленного источника, мы могли бы добавить метод with_field. Этот метод примет поле как общий параметр, который должен быть любым из ключей типа source: не имеет смысла искать поле, которого нет в источнике!

readonly with_field = <k extends keyof source>(k:k) : ...

with_field вернет новый запрос с тем же результатом, что и исходный запрос, включая поле k, которое было в source. Созданный запрос, конечно, по-прежнему основан на исходном source. Его тип такой:

Query<result & { [f in k]:source[k] }, source>

Тип result & { [f in k]:source[k] } включает все поля, которые мы нашли в result, плюс новое поле [f in k] с типом source[k] из k в source.

Десериализация в новом запросе основана на десериализации предыдущего запроса с последующим добавлением нового поля k к десериализованному результату:

(res:any) => {
  let obj = this.deserialize(res) as any
  if (!(k in res)) throw new TypeError(`Error: missing key ${k} in ${JSON.stringify(res)}`)
  obj[k] = res[k]
  return obj as result & { [f in k]:source[k] }
}

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

interface Person { 
  id:number, 
  name:string, 
  surname:string, 
  nationality:string,
  age:number }
let name_age_query = entity_query<Person>()
  .with_field("name")
  .with_field("age")
let res = name_age_query.deserialize({ "id":1, "name":"John", "age":23 })

Десериализованный результат будет иметь тип:

let res: {
    id: number;
} & {
    name: string;
} & {
    age: number;
}

это означает, что попытка чего-то вроде res.surname приведет к ошибке компилятора, а не будет обнаружена где-то во время выполнения. Более того, если мы попытаемся создать запрос, который включает несуществующее поле, например, добавив .with_field(“favorite_food”) к нашему запросу, мы сразу же получим ошибку компилятора, поскольку favorite_food не является допустимым полем для Person. Это может значительно уменьшить количество ошибок, связанных с сериализацией!

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

readonly filter_eq = <k extends keyof result>(k:k, v:result[k]) : Query<result, source> => {
  return new Query(this.deserialize)
}

Обратите внимание, что единственными реальными ограничениями здесь являются то, что k должно быть допустимым свойством result, и что значение для сравнения v должно иметь тот же тип этого свойства. Теперь мы можем написать, например:

let q = entity_query<Person>()
  .with_field("name")
  .with_field("age")
  .filter_eq("name", "Johnny")

Попытка либо .filter_eq(“name”, 3), либо .filter_eq(“City”, “Rotterdam”) приведет к ошибкам компилятора: 3 несовместимо с string, тогда как City не является полем Person. Опять же, это гарантирует, даже без необходимости запуска кода, что наши запросы правильно сформированы.

Расширения

Код, который мы видели до сих пор, относительно прост и охватывает только спецификацию запросов и создание функции десериализации на основе запроса. Можно дополнительно расширить эту структуру, чтобы включить более сложные операции запроса, такие как сортировка, разбивка на страницы, но, что более важно, поиск отношений (особенно один-ко-многим). Более того, мы могли бы даже пойти дальше в этой структуре и сгенерировать сам запрос (будь то OData, GraphQL или что-то еще) из спецификации запроса. Это может стать интересным продолжением этой статьи :)

Psst!

Вы амбициозный разработчик? Понравилась статья? Вы ищете компанию, в которой другие инженеры-программисты работают на высоком уровне, применяя концепции функционального программирования и теории типов для создания красивого и надежного онлайн-программного обеспечения? Тогда не смотрите дальше: в Hoppinger, в прекрасном городе Роттердам, у нас несколько открытых вакансий! Мы принимаем кандидатов на всех уровнях: от ветеранов до желающих учиться юниоров.

Дамп кода - для справки

export class Query<result,source> {
  deserialize : (res:any) => result
  readonly with_field = <k extends keyof source>(k:k) : Query<result & { [f in k]:source[k] }, source> => {
    return new Query(
      (res:any) => {
        let obj = this.deserialize(res) as any
        if (!(k in res)) throw new TypeError(`Error: missing key ${k} in ${JSON.stringify(res)}`)
        obj[k] = res[k]
        return obj as result & { [f in k]:source[k] }
      }
    )
  }
  readonly filter_eq = <k extends keyof result>(k:k, v:result[k]) : Query<result, source> => {
    return new Query(this.deserialize)
  }
  readonly cast = <t1>() : Query<result, source | t1> => {
    return new Query(this.deserialize)
  }
  constructor(deserialize : (res:any) => result) {
    this.deserialize = deserialize
  }
}
export let entity_query = <t extends { id:number }>() =>
  new Query<{ id:t["id"] }, t>(
    res => "id" in res && typeof res["id"] === "number" ?
        ({ id:res["id"] as number })
      : fail(`Error: expected id in ${JSON.stringify(res)}`)
  )
interface Person { id:number, name:string, surname:string, age:number }
let q = entity_query<Person>()
  .with_field("name")
  .with_field("age")
  .filter_eq("name", "Johnny")
let res = q.deserialize({ "id":1, "name":"John", "age":23 })