На протяжении всей этой статьи мы будем изучать и объяснять некоторые части кода ValidTs. Даже опытный пользователь TypeScript может научиться одному или двум приемам. Ожидается, что читатель имеет общее представление о языке.
TLDR
Вот список ссылок на некоторые интересные функции TypeScript, которые мы будем использовать:
unknown
тип- меченый союз
- условные типы
- типовая гвардия
- функции утверждения
const
утверждения- Вывод типа кортежа из общих остальных параметров
- перегрузки функций
- типы индексов
- сопоставляемые типы
Проверка в целом
Когда мы имеем дело с любым внешним источником данных, мы не можем делать никаких предположений о полученных данных. Довольно часто частный 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”
, тем более удобной в сопровождении и защищенной от ошибок становится ваша кодовая база.