На протяжении всей этой статьи мы будем изучать и объяснять некоторые части кода ValidTs. Даже опытный пользователь TypeScript может научиться одному или двум приемам. Ожидается, что читатель имеет общее представление о языке.

TLDR

Вот список ссылок на некоторые интересные функции TypeScript, которые мы будем использовать:

Проверка в целом

Когда мы имеем дело с любым внешним источником данных, мы не можем делать никаких предположений о полученных данных. Довольно часто частный API веб-сервера просто приводит результат JSON.parse к какому-то известному типу или даже оставляет его как any. Пример объяснения для этого может выглядеть следующим образом: «В любом случае это частный API, и та же команда работает над кодом на стороне клиента». Это довольно удобно, когда вы просто хакаете, но не очень масштабируемо. В лучшем случае недействительные клиентские запросы заканчиваются как «невозможно прочитать X из неопределенных» в отчетах об ошибках на стороне сервера. В худшем случае произойдет что-то неожиданное.

JSON.parse всегда возвращал any. Однако я бы сказал, что с введением типа unknown в TypeScript кажется, что unknown будет для него более подходящим возвращаемым типом. any побуждает людей использовать что-то любым способом, а unknown требует некоторой работы. Если вы хотите увидеть пример того, как статически типизированный язык обрабатывает синтаксический анализ JSON, взгляните на Декодеры Elm JSON. Идея библиотеки ValidTs состоит в том, чтобы позволить пользователю легко определять валидаторы, которые безопасно превращают any в конкретные типы.

Тип результата

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

enum ResultKind { Ok, Err }
type Ok<T> = { kind: ResultKind.Ok; value: T };
type AnyOk = Ok<any>;
type Err<T> = { kind: ResultKind.Err; value: T };
type AnyErr = Err<any>;
type Result<O, E> = Ok<O> | Err<E>;
type AnyResult = AnyOk | AnyErr;

Обратите внимание, что enum, определенный таким образом, будет использовать целые числа вместо Ok и Err.

С введением условных типов легко превратить Ok<number> | Err<”invalid_number”> в Ok<number> с FilterOk или Err<”invalid_number”> с FilterErr.

type FilterOk<T extends AnyResult> = 
  Extract<T, { kind: ResultKind.Ok }>;
type FilterErr<T extends AnyResult> = 
  Extract<T, { kind: ResultKind.Err }>;

Мы также определяем еще один помощник, который просто превращает Ok<number> в number или Err<”invalid_number”> в ”invalid_number”.

type UnwrapOk<O extends AnyOk> = O["value"];
type UnwrapErr<E extends AnyErr> = E["value"];

Вместо сравнения result.kind === ResultKind.Ok мы могли бы использовать вспомогательную функцию. Вот определение нашей защиты типа.

const isOk = <R extends AnyResult>(result: R): 
  result is FilterOk<R> => result.kind === ResultKind.Ok;

С TypeScript 3.7 мы также можем определять аналогичные утверждения.

function assertOk<R extends AnyResult>(result: R): 
  asserts result is FilterOk<R> {
    if (!isOk(result)) { throw new Error("Expected Ok"); }
  }

Вооружившись этими помощниками, мы можем перейти к нашим валидаторам.

Тип валидатора

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

type Validator<I, O extends AnyResult> = (input: I) => O;

Идея возврата Result вместо boolean для обозначения результата проверки заключается в том, что мы хотим позволить нашим валидаторам изменять свои входные данные и возвращать результат этого изменения в качестве их успешного вывода. Это сделает их более гибкими, позволяя выполнять приведение/принуждение ввода внутри них.

Опять же, используя условные типы, мы можем получить входные и выходные типы наших валидаторов, когда нам это нужно.

type ExtractValidatorI<V> = 
  V extends Validator<infer I, any> ? I : never;
type ExtractValidatorO<V> = 
  V extends Validator<any, infer O> ? O : never;

Простые валидаторы

Начнем с реализации простого валидатора равенства. Чтобы реализовать любой валидатор, все, что нам нужно сделать, это удовлетворить интерфейс Validator<I, O>, указанный выше. Валидатор равенства принимает любые входные данные. Если ввод соответствует ожидаемому значению, он возвращает Ok<T>. В противном случае он сообщит Err<”equality_error”>.

type EqOutput<T> = Ok<T> | Err<"equality_error">;
const eq = <T>(expectedValue: T): Validator<any, EqOutput<T>> =>
  (input) => input === expectedValue 
    ? ok(input) 
    : err("equality_error");

Вот и все. Теперь любое значение, прошедшее проверку на равенство, будет правильно напечатано. Например:

const validator = eq("some_const_string" as const)
const validation = validator(<input>)
if (isOk(validation)) {
  // validation.value is correctly typed to "some_const_string"
} else {
  // validation.value is correctly typed to "equality_error"
}

Обратите внимание на использование as const, доступное начиная с Typescript 3.4. Благодаря этому выражение ”some_const_string” печатается как ”some_const_string”, а не просто string. Это очень полезный инструмент для любого постоянного значения, а не только для строк.

Взгляните на incl, number, string, boolean, optional и nullable, чтобы увидеть другие примеры простых валидаторов.

Сложные валидаторы

Валидатор «Или»

Попробуем сначала разобраться с валидатором or. Вот пример использования:

const validator = or(string, number, boolean)
const validation = validator(<input>)
if (isOk(validation)) {
  // validation.value is correctly typed 
  // to `string | number | boolean`
} else {
  // validation.value is correctly typed to
  // {
  //   kind: "all_failed",
  //   errors: Array<
  //     "string_error" | "number_error" | "boolean_error"
  //   >
  // }
}

Как мы видим, конструктор валидатора or является вариационной функцией — он имеет бесконечную арность. Его возвращаемый тип — Validator<OrInput, OrOutput>. Чтобы ввести OrInput и OrOutput, нам нужно просмотреть валидаторы, переданные конструктору.

Вот хитрость: чтобы превратить кортеж [boolean, string] в тип объединения boolean | string (или массив Array<boolean | string> в boolean | string), вы можете выбрать из него [number]: [boolean, string][number]. Мы будем использовать это, чтобы получить комбинированные типы Ok и Err из всех разных валидаторов, переданных в or.

Давайте теперь определим конструктор валидатора or:

const or = <Vs extends AnyValidator[]>(...validators: Vs):
  Validator<OrInput<Vs>, OrOutput<Vs>> => {
    // (...)
  }

Как и было обещано, это функция с переменным числом аргументов, которая возвращает валидатор. Используя упомянутый выше прием и наш помощник ExtractValidatorI, мы можем определить ввод комбинированного валидатора как альтернативу всем вводам валидатора, переданным в конструктор:

type OrInput<Vs extends AnyValidator[]> = 
  ExtractValidatorI<Vs[number]>;

Ввод вывода немного сложнее. Нам нужна альтернатива всех успехов или всех ошибок, завернутых в ошибку «все неудачно». Мы можем воспользоваться преимуществами всех помощников, определенных выше: ExtractValidatorO, FilterOk, FilterErr и UnwrapErr. Взгляните на окончательный результат:

type OrOutput<Vs extends AnyValidator[]> = 
  OrOutputOk<Vs> | OrOutputErr<Vs>;
type OrOutputOk<Vs extends AnyValidator[]> = 
  FilterOk<ExtractValidatorO<Vs[number]>>;
type OrOutputErr<Vs extends AnyValidator[]> =
  Err<
    {
      kind: "all_failed",
      errors: Array<
        UnwrapErr<FilterErr<ExtractValidatorO<Vs[number]>>>
      >,
    }
  >;

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

Валидатор «И»

Наш валидаторand работает аналогично оператору &&. Он создает валидатор, который сообщает о первой обнаруженной ошибке. Если ошибок не возникает, возвращается вывод последнего валидатора. Каждый валидатор передает свой вывод в качестве ввода следующему. Я не слишком хорошо разбираюсь в функциональном программировании, но я бы сказал, что это работает не так, как композиция Клейсли из монады Либо. Вот пример использования:

const validator = and(string, (str) => {
  // Note that `str` is typed as `string`
  const parsed = parseInt(str)
return Number.isNan(parsed) 
    ? err("cast_integer_error" as const) 
    : ok(parsed)
})
const validation = validator("123")
if (isOk(validation)) {
  // validation.value is typed as `number` 
  // and has value of `123`
} else {
  // validation.value is typed as 
  // `"string_error" | "cast_integer_error"`
}

Довольно сложно выразить часть «каждый валидатор передает свои выходные данные в качестве входных данных для следующего». Например, мы хотим, чтобы гипотетический and(string, moreThan(3)) не работал во время компиляции, предполагая, что валидатор string выводит значение типа string, а moreThan(3) ожидает ввода типа number.

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

interface And {
  // (...)
  // case for arity 4
  // case for arity 3
  // case for arity 2
  // case for infinite arity
}
export const and: And = (...validators: any) => {
  // (...)
}

Вот что я сделал для арности двух:

<
  I1, 
  O1 extends AnyResult, 
  I2 extends UnwrapOk<FilterOk<O1>>, 
  O2 extends AnyResult
>(v1: Validator<I1, O1>, v2: Validator<I2, O2>): 
  Validator<I1, O2 | FilterErr<O1>>

Важными частями, которые нужно увидеть, являются I2 extends UnwrapOk<FilterOk<O1>> (что гарантирует, что второй валидатор ожидает получить успешный вывод предыдущего валидатора в качестве своего ввода) и Validator<I1, O2 | FilterErr<O1>> (который сообщает нам, что ожидает и возвращает результирующий валидатор).

Мы не можем определить падеж для каждой арности. Я определил компромиссный всеобъемлющий случай для обработки бесконечной арности за счет проверки той части, что «следующий валидатор ожидает получить успешный вывод предыдущего валидатора в качестве входных данных».

<Vs extends AnyValidator[]>(...validators: Vs): Validator<
  ExtractValidatorI<Vs[0]>, 
  FilterOk<ExtractValidatorO<LastTupleElem<Vs>>> | 
    FilterErr<ExtractValidatorO<Vs[number]>>
>;

Как видите, мы заменили I1 из предыдущего примера на ExtractValidatorI<Vs[0]>. Начиная с TypeScript 3.0, общие вариативные аргументы обрабатываются как кортежи. В приведенном выше примере общий тип Vs выводится как кортеж, и мы можем выбрать из него первый элемент: Vs[0].

O2 | был заменен на FilterOk<ExtractValidatorO<LastTupleElem<Vs>>> |. Он берет последний элемент кортежа Vs, извлекает вывод этого валидатора и фильтрует его успешность. LastTupleElem здесь довольно интересно. Для этого я украл фишку из библиотеки SimpleTyped.

type Prev<T extends number> = [-1, 0, 1, 2, 3, 4, 5, 6, 7, (...)
type Length<T extends any[]> = T["length"];
type LastTupleElem<T extends any[]> = T[Prev<Length<T>>];

Ну вот! У нас есть очень мощный инструмент для выражения набора проверок, который также может включать приведение типов и принуждение. Мы можем определить весь конвейер, который будет выполняться для определенного значения.

Валидатор формы

Последний валидатор, который мы рассмотрим, — это валидатор shape. Это позволяет определить валидатор на основе заданной формы объекта. Как всегда, тип успешной и ошибочной проверки правильно определяется. Например:

const validator = shape({
  name: string,
  age: and(string, (str) => {
    const parsed = parseInt(str)
    return Number.isNan(parsed) 
      ? err("cast_integer_error" as const) 
      : ok(parsed)
  })
})
const validation = validator(<anything>)
if (isOk(validation)) {
  // validation.value is typed as `{ name: string, age: number}`
} else {
  // validation.value is typed as
  // {
  //   kind: "shape_error",
  //   errors: Array<
  //     { field: "name", error: "string_error" },
  //     { field: "age", error: "string_error" | 
  //       "cast_integer_error" },
  //   >
  // }
}

Как видно из использования, все вращается вокруг определения схемы. Скоро мы узнаем, что это за тип. Однако давайте сначала определим конструктор валидатора shape как функцию, которая принимает схему и возвращает валидатор с выводом, полученным из схемы:

const shape = <S extends Schema>(schema: S): 
  Validator<any, ShapeOutput<S>> => (input) => { (...) }

Как мы видим выше, Schema — это просто сопоставление поля с валидатором поля. Мы можем добиться этого с помощью типа индекса:

type Schema = { [field: string]: AnyValidator };

ShapeOutput определяется как объединение ShapeOutputOk и ShapeOutputErr:

type ShapeOutput<S extends Schema> = 
  ShapeOutputOk<S> | ShapeOutputErr<S>;

В определении ShapeOutputOk используются уже известные нам вспомогательные функции и отображенные типы:

type ShapeOutputOk<S extends Schema> = Ok<
  { [K in keyof S]: UnwrapOk<FilterOk<ExtractValidatorO<S[K]>>> }
>;

То, что мы делаем с ShapeOutputErr, более сложно. Начнем с конечного результата:

type ShapeOutputErr<S extends Schema> =
  Err<
    {
      kind: "shape_error",
      errors: Array<{
        [K in keyof S]: {
          field: K,
          error: UnwrapErr<FilterErr<ExtractValidatorO<S[K]>>>,
        }
      }[keyof S]>,
    }
  >

Происходит следующее:

1. У нас есть схема:

{
  name: Validator<
    any, 
    Ok<string> | Err<"string_error">
  >,
  age: Validator<
    any, 
    Ok<number> | Err<"string_error"> | Err<"cast_integer_error">
  >,
}

2. Превращаем в:

{
  name: { 
    field: "name", 
    error: "string_error" 
  },
  age: { 
    field: "name", 
    error: "string_error" | "cast_integer_error" 
  },
}

используя:

{
  [K in keyof S]: {
    field: K,
    error: UnwrapErr<FilterErr<ExtractValidatorO<S[K]>>>,
  }
}

3. Затем мы превращаем его в:

{ field: "name", error: "string_error" } |
  { field: "age", error: "string_error" | "cast_integer_error" }

выбирая поля с [keyof S].

4. Наконец, мы оборачиваем его в Array<T>.

Это было бы все для этого сложного дела. С or, eq и shape вы можете делать странные вещи, например, автоматически определять тип объединения:

const reservationCommandValidator = or(
  shape({
    kind: eq("RequestTicketReservation" as const),
    ticketId: number
  }),
  shape({
    kind: eq("RevokeTicketReservation" as const),
    reservationId: string
  }),
  shape({
    kind: eq("ArchiveTicketReservation" as const),
    reservationId: string
  })
);

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

Ознакомьтесь с другими сложными валидаторами: all, array и dict.

Постлюдия

Я надеюсь, что это окажется полезным для кого-то. Я довольно часто получаю выгоду от описанных выше функций. Чем чаще вам удается изменить any на конкретный тип или string на что-то вроде ”RequestTicketReservation”, тем более удобной в сопровождении и защищенной от ошибок становится ваша кодовая база.