Что такое io-ts и зачем он нужен?
io-ts — это библиотека для TypeScript, которая решает одну из самых болезненных проблем в разработке на этом языке: расхождение между статическими типами (во время компиляции) и реальными данными (во время выполнения). TypeScript — замечательный инструмент, но его система типов работает только на этапе разработки. Когда ваше приложение запускается, все типы стираются. Если вы получаете данные из внешнего API, из формы на сайте, из localStorage или из любого другого источника, не контролируемого вашим компилятором, вы не можете быть уверены, что данные соответствуют заявленным типам.
Здесь на помощь приходит runtime-валидация. io-ts позволяет вам определить схему данных один раз, а затем использовать её для двух целей:
- Статическая типизация (compile-time): TypeScript выводит типы на основе вашей схемы, обеспечивая безопасность кода на этапе написания.
- Валидация во время выполнения (runtime): io-ts проверяет, соответствуют ли реальные данные вашей схеме, и возвращает либо успешный результат, либо подробную ошибку.
Библиотека основана на концепции кодеков (codecs). Кодек — это объект, который знает, как декодировать (проверить и преобразовать) необработанные данные в тип TypeScript и как кодировать (сериализовать) этот тип обратно. Это делает io-ts мощным инструментом не только для валидации, но и для безопасной сериализации/десериализации данных.
Установка
io-ts — это библиотека для экосистемы Node.js и браузеров, поэтому устанавливается она через npm (или yarn). Обратите внимание, что io-ts требует fp-ts в качестве peer dependency, так как использует функциональные концепции, такие как Either и Option.
npm install io-ts fp-ts
Или, если вы используете yarn:
yarn add io-ts fp-ts
После установки вы можете импортировать библиотеку в свой проект:
import * as t from 'io-ts';
import { isRight, isLeft } from 'fp-ts/Either';
Быстрый старт: первый пример
Давайте начнем с простого примера. Предположим, мы ожидаем объект пользователя с полями name (строка) и age (число). Определим схему с помощью io-ts и попробуем провалидировать некоторые данные.
import * as t from 'io-ts';
import { isRight, isLeft } from 'fp-ts/Either';
// 1. Определяем схему (codec)
const User = t.type({
name: t.string,
age: t.number,
});
// 2. Функция для безопасного получения типа
type User = t.TypeOf<typeof User>;
// 3. Данные для проверки
const validData = { name: 'Alice', age: 30 };
const invalidData = { name: 'Bob', age: 'двадцать' }; // age - строка, а не число
// 4. Валидация
const result1 = User.decode(validData);
const result2 = User.decode(invalidData);
console.log(isRight(result1)); // true
console.log(isLeft(result2)); // true
// 5. Извлекаем данные (если валидация прошла)
if (isRight(result1)) {
const user: User = result1.right;
console.log(user.name); // Alice
}
// 6. Обрабатываем ошибку
if (isLeft(result2)) {
console.error('Ошибка валидации:', result2.left);
// Выведет список ошибок, например:
// [ { value: 'двадцать', context: [...], message: 'Invalid value "двадцать" supplied to : { name: string, age: number }/age: number' } ]
}
В этом примере мы видим ключевой паттерн: decode возвращает Either<Errors, A> из библиотеки fp-ts. Either — это контейнер, который может содержать либо ошибку (левый тип), либо успешное значение (правый тип). Мы проверяем результат с помощью функций isRight и isLeft.
Основные методы и функции io-ts
io-ts предоставляет богатый набор примитивов и комбинаторов для построения сложных схем. Ниже приведены 10 наиболее часто используемых методов с подробным описанием и примерами.
1. t.string
Что делает: Создает кодек для валидации строковых значений. Проверяет, что переданное значение имеет тип string в JavaScript.
Параметры: Нет. Это готовый кодек.
const StringCodec = t.string;
const res1 = StringCodec.decode('hello'); // isRight(res1) === true
const res2 = StringCodec.decode(123); // isLeft(res2) === true
const res3 = StringCodec.decode(null); // isLeft(res3) === true
2. t.number
Что делает: Создает кодек для валидации чисел. Важно: NaN и Infinity также считаются числами в JavaScript, но io-ts по умолчанию их пропускает. Для строгой проверки используйте t.Int (целые числа) или t.refinement.
Параметры: Нет.
const NumberCodec = t.number;
const res1 = NumberCodec.decode(42); // OK
const res2 = NumberCodec.decode('42'); // Ошибка: строка
const res3 = NumberCodec.decode(NaN); // OK (NaN — это number)
3. t.boolean
Что делает: Кодек для булевых значений true или false.
Параметры: Нет.
const BooleanCodec = t.boolean;
const res1 = BooleanCodec.decode(true); // OK
const res2 = BooleanCodec.decode(1); // Ошибка: 1 — это не boolean
4. t.literal
Что делает: Создает кодек, который принимает только одно конкретное значение (литерал). Полезно для создания discriminated unions (размеченных объединений).
Параметры: value: A — конкретное значение, которое должен иметь тип.
const StatusSuccess = t.literal('success');
const StatusError = t.literal('error');
const res1 = StatusSuccess.decode('success'); // OK
const res2 = StatusSuccess.decode('error'); // Ошибка
5. t.type
Что делает: Создает кодек для объекта с фиксированным набором полей. Это аналог интерфейса TypeScript. Все поля обязательны по умолчанию.
Параметры: props: Record<string, t.Mixed> — объект, где ключи — это имена полей, а значения — кодеки для этих полей.
const Person = t.type({
firstName: t.string,
lastName: t.string,
age: t.number,
});
type Person = t.TypeOf<typeof Person>;
// Person = { firstName: string; lastName: string; age: number }
const res1 = Person.decode({ firstName: 'John', lastName: 'Doe', age: 25 }); // OK
const res2 = Person.decode({ firstName: 'John' }); // Ошибка: отсутствуют lastName и age
6. t.partial
Что делает: Создает кодек для объекта, все поля которого являются необязательными. Аналог TypeScript Partial<T>.
Параметры: props: Record<string, t.Mixed>.
const PartialPerson = t.partial({
firstName: t.string,
lastName: t.string,
});
const res1 = PartialPerson.decode({ firstName: 'Jane' }); // OK
const res2 = PartialPerson.decode({}); // OK (все поля опциональны)
const res3 = PartialPerson.decode({ firstName: 123 }); // Ошибка: firstName должен быть строкой
7. t.union
Что делает: Создает кодек для объединения типов (аналог | в TypeScript). Пытается декодировать значение поочередно каждым кодеком из списка. Возвращает первую успешную попытку.
Параметры: codecs: t.Mixed[] — массив кодеков.
const StringOrNumber = t.union([t.string, t.number]);
const res1 = StringOrNumber.decode('hello'); // OK (строка)
const res2 = StringOrNumber.decode(42); // OK (число)
const res3 = StringOrNumber.decode(true); // Ошибка: не строка и не число
8. t.array
Что делает: Создает кодек для массива, каждый элемент которого должен соответствовать переданному кодеку.
Параметры: codec: t.Mixed — кодек для элементов массива.
const NumberArray = t.array(t.number);
const res1 = NumberArray.decode([1, 2, 3]); // OK
const res2 = NumberArray.decode([1, '2', 3]); // Ошибка: второй элемент — строка
const res3 = NumberArray.decode('not array'); // Ошибка: не массив
9. t.record
Что делает: Создает кодек для объекта, используемого как словарь (Record). Все ключи должны быть строками, а все значения должны соответствовать переданному кодеку.
Параметры: codec: t.Mixed — кодек для значений.
const StringRecord = t.record(t.string); // { [key: string]: string }
const res1 = StringRecord.decode({ a: 'apple', b: 'banana' }); // OK
const res2 = StringRecord.decode({ a: 1 }); // Ошибка: значение не строка
10. t.refinement
Что делает: Создает кодек, который добавляет дополнительную проверку (предикат) к существующему кодеку. Используется для создания пользовательских типов, например, "положительное число" или "непустая строка".
Параметры: codec: t.Type<A, O, I> — базовый кодек; refinement: (a: A) => boolean — функция-предикат; name?: string — имя для сообщений об ошибках.
const PositiveNumber = t.refinement(t.number, (n) => n > 0, 'PositiveNumber');
const res1 = PositiveNumber.decode(5); // OK
const res2 = PositiveNumber.decode(-1); // Ошибка: не прошло refinement
const res3 = PositiveNumber.decode('a'); // Ошибка: не число (ошибка от базового кодекса)
Реальный практический пример: Валидация ответа от REST API
Представим, что мы пишем клиент для погодного API. Сервер возвращает JSON следующей структуры:
{
"location": "Moscow",
"temperature": 22.5,
"conditions": "clear",
"forecast": [
{ "day": "Monday", "temp_high": 25, "temp_low": 18 },
{ "day": "Tuesday", "temp_high": 23, "temp_low": 17 }
]
}
Мы хотим гарантировать, что данные, пришедшие с сервера, имеют правильную структуру, иначе наше приложение может упасть с ошибкой. Создадим полную схему и функцию для безопасного запроса.
import * as t from 'io-ts';
import { isRight, isLeft } from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';
import { fold } from 'fp-ts/Either';
// 1. Определяем