Обобщенные фабрики тайпгардов в TypeScript
Привет, Хабр! Меня зовут Сергей Соловьев, я разрабатываю интерфейсы международных платежей в Тинькофф Бизнесе. Уверен, многие хотят писать надежный, поддерживаемый, но при этом лаконичный код. Как найти баланс?
TypeScript упрощает разработку веб-приложений ценой роста кодовой базы. В большинстве случаев накладные расходы, связанные с аннотацией типов, невелики и полностью компенсируются выигрышем в надежности и поддерживаемости кода. TS всеми силами пытается облегчить нам работу за счет мощной системы вывода типов, но компилятор языка не всесилен, и порой нам приходится самим указывать, какие типы мы хотим видеть.
Частный случай решения проблемы сужения типов — функции-тайпгарды. Это полезная фича языка, но ее применение может привести к появлению однотипного кода и даже увеличить риск возникновения багов. В статье рассмотрим проблемы, связанные с использованием функций-тайпгардов и один из способов их решения — создание обобщенных фабрик.
Что такое тайпгарды и предикаты типа
В компиляторе TypeScript реализован механизм сужения типов. Так называется автоматическое определение подтипа исходного типа переменной в конкретном контексте на основе анализа потока управления.
TS понимает, что переменная user
после проверки в условии if
не может иметь значение null
. Поэтому в положительной ветке if
компилятор приписывает переменной user
более узкий тип User
, вместо изначального User | null
. Благодаря этому мы можем обратиться к полю user.name
, избежав ошибки компиляции 'user' is possibly 'null'
:
type User = { name: string };
let user: User | null = getUser();
if (user) {
// let user: User; <-- суженный тип переменной в контексте `if`
console.log(user.name)
}
Примененный в коде механизм называется сужением типов, а о конструкции в условии if
в данном случае говорят, что она выступает в роли тайпгарда — защитника типа, type guard.
Для сужения типов в TS могут использоваться операторы сравнения, присваивания, typeof
, instanceof
, in
. Полный перечень можно изучить в официальной документации. Но иногда возможностей встроенных тайпгардов не хватает, тогда мы можем определить свои собственные функции для проверки типов:
function isUser(x: unknown): x is User {
return typeof x === 'object'
&& x !== null
&& 'name' in x
&& typeof x.name === 'string';
}
Функция isUser
и подобные ей называются пользовательскими тайпгардами. Их главное отличие — предикат типа в качестве аннотации типа возвращаемого значения. Предикатом типа (type predicate) называется синтаксическая конструкция вида x is T
, где x
— имя параметра функции, а T
— подтип исходного типа x
(формально: T extends typeof x
). Предикат типа синтаксически фиксирует интерпретация булева результата функции.
Важно отметить, что с точки зрения компилятора переменная x
имеет тип T
, если и только если тайпгард возвращает true
, поэтому проверка в теле пользовательского тайпгарда должна быть строгой: возвращать ложь тогда и только тогда, когда значение аргумента x
не относится к типу T
.
«Предикат типа» не стоит путать с «функцией-предикатом» (predicate function). Функцией-предикатом, или просто предикатом, часто называют любую функцию, которая возвращает булево значение (формально: (...args: any[]) => boolean
). Как следствие, любой пользовательский тайпгард можно назвать предикатом, поскольку он возвращает boolean
.
Мы будем говорить именно о пользовательских тайпгардах, поэтому для краткости будем называть их просто тайпгардами.
Проверка на равенство значений
Начнем с простого, но показательного примера: есть массив примитивных значений и мы хотим отфильтровать определенные элементы. Для этого можем использовать лямбда-функцию, например так:
// const onlyA: string[];
const onlyA = ['a', 'b', 'c'].filter(x => x === 'a');
У решения есть существенный недостаток. Хоть мы и знаем, что в отфильтрованном массиве могут содержаться только значения типа 'a', TS оставляет тип массива более широким string[]. Он не видит в нашей лямбда-функции тайпгард, а значит, не может использовать соответствующую сужающую перегрузку метода filter.
Про метод массива filter
Стандартный класс Array
в JS содержит метод filter
, который выполняет фильтрацию на основе предиката. Во встроенной в TS библиотеке типов es5 этот метод содержит две перегрузки. Благодаря этому, если методу передать подходящий тайпгард, TS на основе его сигнатуры сузит тип отфильтрованного массива.
interface Array {
filter(
predicate: (value: T, index: number, array: T[]) => value is S,
thisArg?: any
): S[];
filter(
predicate: (value: T, index: number, array: T[]) => unknown,
thisArg?: any
): T[];
/* ... Other members ... */
}
Ссылка на исходный код на github
Решить проблему можно, явно типизировав фильтрующий предикат как тайпгард, снабдив лямбду предикатом типа:
// const onlyA: 'a'[];
const onlyA = ['a', 'b', 'c'].filter(
(x): x is 'a' => x === 'a'
);
Вариант выглядит неплохо, но при необходимости переиспользовать условие фильтрации приводит к дублированию кода. Вынесем логику проверки в отдельную функцию:
function isA(x: unknown): x is 'a' {
return x === 'b';
}
// const onlyA: 'a'[];
const onlyA = ['a', 'b', 'c'].filter(isA);
Тайпгард isA
можно использовать повторно, но для каждого нового фильтруемого значения придется определить свой тайпгард, что породит много однотипного кода.
Типизация фильтрующего предиката и вынесение логики в функцию подвержены одной и той же проблеме. В коде isA
допущена ошибка: в теле тайпгарда (x === 'b'
) проверяется не то, что указано в предикате типа (x is 'a'
). Компилятор тут бессилен, потому что смысловая согласованность тела функции и предиката типа не гарантирована синтаксически. Ошибка такого рода может возникнуть в результате изменения существующей кодовой базы или при создании однотипных тайпгардов путем копирования, особенно при плохом тестовом покрытии.
Но ведь мы же используем TypeScript, чтобы избавиться от ошибок, а не создавать их. Используем его силу, чтобы преодолеть его же слабость! Создадим обобщенную фабрику тайпгардов, которая выступит высшим арбитром и обеспечит синтаксическую связь между проверкой в теле тайпгарда и предикатом типа. Для простоты и наглядности ограничимся проверкой на равенство примитивам:
type Primitive = string | number | boolean | bigint | symbol | undefined | null;
function is(matcher: T) {
return (x: unknown): x is T => x === matcher;
}
Благодаря введению функции второго порядка мы синтаксически закрепили связь между типом эталонного значения matcher: T
и результатом сужения в предикате типа x is T
. Имея на руках такую функцию, можно изящно решить задачу из примера выше:
// const onlyA: 'a'[];
const onlyA = ['a', 'b', 'c'].filter(is('a'));
Стоит отметить, что функция будет корректно работать с перечислениями, поскольку они относятся к примитивным типам. Например, отфильтруем значения в потоке:
enum State { Error, Ok }
const state$ = new Subject();
// const ok$: Observable;
const ok$ = state$.pipe(filter(is(State.Ok)));
Обработка граничных случаев
Выжмем из TypeScript еще немного безопасности для нашего кода. Рассмотрим ситуацию, в которой мы случайно передаем в фабрику значение, которого не может быть в исходном массиве:
// const only2: string[];
const only2 = ['a', 'b', 'c'].filter(is(2));
Поскольку сконструированный фабрикой тайпгард не совместим с тайпгардом для значений из массива, то применяется обычная перегрузка метода filter
. В итоге тип отфильтрованного массива будет совпадать с исходным, хотя еще на этапе компиляции мы достоверно знаем, что в результате фильтрации получится пустой массив (формально: []
). Поскольку такая фильтрация не имеет смысла, попробуем запретить ее на уровне типов, чтобы уберечь незадачливого программиста от ошибки по невнимательности:
function is(matcher: T) {
return (x: S): x is T => x === matcher as Primitive;
}
Мы добавляем обобщенный тип S
, который представляет собой базовый тип для сужения, и ограничиваем им возможный результат сужения T extends S & Primitive
. Ограничение примитивными типами для T
остается. Тип S
выводится как ожидаемый тип аргумента фильтрующей функции-предиката. В контексте метода filter
он выводится как тип элемента массива, благодаря чему на этапе написания кода возникает ошибка компилятора:
// ❌ Argument of type 'number' is not assignable to parameter of type 'string'.
const only2 = ['a', 'b', 'c'].filter(is(2));
Добавление второго обобщенного типа S
даже без значения по умолчанию не мешает вызову фабрики вне контекста метода filter
. В таком случае TS, не найдя каких-либо ограничивающих факторов для S
, подставит вместо него unknown
, что нас полностью устраивает:
// const is2: (x: unknown) => x is 2;
const is2 = is(2);
Можно найти и другие граничные случаи, обработка которых сделает решение более надежным и удобным в использовании, но мы ограничимся текущим вариантом.
Проверка на экземпляр класса
Еще один распространенный вариант сужения типов в TypeScript — проверка на экземпляр класса. В JS принадлежность значения классу проверяется при помощи двухместного оператора instanceof
. Слева от оператора требуется передать проверяемый объект, а справа — класс, a.k.a. функцию-конструктор. Instanceof
сам по себе является тайпгардом, но для фильтрации списков или для применения в Angular-шаблонах требуется завернуть его в пользовательский тайпгард. Для начала определим тип функции-конструктора:
interface Constructor {
new(...args: P): T
}
По умолчанию мы используем never
для списка параметров. Так мы гарантируем, что не сможем вызвать конструктор, если тип списка параметров конструктора P
не указан явно. А теперь попробуем реализовать обобщенную фабрику:
export function instanceOf(clazz: Constructor) {
return (x: S): x is T => x instanceof clazz;
}
Мы снова проделали трюк с введением второго обобщенного типа S
, но в данном случае это выглядит даже проще. А вот так выглядит типичный случай применения этой фабрики:
class A { a?: string }
class B { b?: number }
class B1 extends B { b?: 1 }
class B2 extends B { b?: 2 }
const b: B[] = [new B1(), new B2()];
// const onlyB1: B1[];
const onlyB1 = b.filter(instanceOf(B1));
// ❌ Type 'A' has no properties in common with type 'B'.
const onlyA = b.filter(instanceOf(A));
В решении можно найти изъяны, но оно покрывает большинство сценариев использования.
Объединение тайпгардов
Возьмем задачу посложнее. Представим, что у нас есть набор событий, каждое из которых описано классом. А еще есть тип, объединяющий все эти события. Упрощенно это можно представить так:
class Event1 { name = 'event1' as const }
class Event2 { name = 'event2' as const }
class Event3 { name = 'event3' as const }
type Event = Event1 | Event2 | Event3;
Наконец, у нас есть поток событий events$: Observable
и стоит задача отфильтровать событие в потоке. Теперь благодаря фабрике instanceOf
мы можем легко отфильтровать событие. Но что, если нам требуется отфильтровать не одно, а несколько событий из общего числа?
Именно такая задача стояла недавно передо мной, что и привело к написанию этой статьи. Пошарив по репозиторию, я обнаружил в нем несколько тайпгардов вида (e: Event) => e is Event1 | Event2
, которые фильтруют события попарно. Подходящего мне тайпгарда среди них не было… Можно было просто создать еще один, но это решение несло бы все те проблемы, которые мы разбирали в проверке на равенство значений. Поэтому сразу же появилась идея решить эту задачу в общем виде. В итоге я реализовал фабрику для комбинирования тайпгардов.
Разберем решение. Для удобства введем псевдоним для типа функции-тайпгарда:
type Guard = (x: P) => x is T;
Применение any
вместо never
в качестве типа по умолчанию — вынужденная необходимость из-за ограничения T extends P
, вытекающего из природы тайпгарда. В остальных случаях правильнее использовать never
для типа по умолчанию аргументов обобщенных типов функций.
Реализуем саму фабрику someOf
:
function someOf(...guards: T) {
return (x => guards.some(guard => guard(x))) as T[number];
}
В результате вызова этой фабрики конструируется функция, тип которой представляет собой объединение типов исходных тайпгардов:
// const is2or3: ((x: unknown) => x is 2) | ((x: unknown) => x is 3)
const is2or3 = someOf(is(2), is(3));
Несмотря на сомнительный облик типа объединения функций, решение вполне рабочее, хотя иногда приводит к проблемам. Можно реализовать фабрику более красиво и надежно, чтобы на выходе получить настоящий тайпгард:
type SomeOf =
T[number] extends Guard
? (x: P) => x is R
: never;
function someOf(...guards: T) {
return (x => guards.some(guard => guard(x))) as SomeOf;
}
Проверим, какой тип теперь на выходе фабрики:
// const is2or3: (x: unknown) => x is 2 | 3
const is2or3 = someOf(is(2), is(3));
Вернемся к изначальной задаче:
const events$ = new Subject();
// const filtered$: Observable
const filtered$ = events$.pipe(
filter(someOf(instanceOf(Event1), instanceOf(Event2))),
);
В нашей реализации есть что улучшать. Например, можно попробовать ввести обобщенный тип S
, как и в других примерах, чтобы на этапе компиляции отсекать бессмысленные проверки, которые априори ведут к пустому массиву. Есть и другие корнер-кейсы, которые можно обработать лучше, но такой реализации уже хватает для большинства возможных сценариев.
Важно, что эту фабрику можно использовать с любыми тайпгардами, а не только с is
и instanceOf
. В том числе ее можно применять к тайпгардам, тип аргумента которых изначально уже, чем unknown
.
Как применить фабрики тайпгардов в жизни
Небольшой бонус! Я нашел код в одном из наших Angular-проектов:
const started$ = router.events.pipe(
filter(
(event): event is NavigationStart => event instanceof NavigationStart,
),
);
const finished$ = router.events.pipe(
filter(
(event): event is NavigationEnd | NavigationCancel | NavigationError =>
event instanceof NavigationEnd ||
event instanceof NavigationCancel ||
event instanceof NavigationError,
),
);
В коде создаются два производных потока на основе потока событий роутера, и проблемы в таком решении налицо. Код далек от DRY: идентификатор event
повторяется в нем восемь раз! Название каждого класса встречается дважды. Соответствие предиката типа и тела тайпгарда синтаксически не гарантировано, что может легко привести к рассогласованию и, как следствие, багам.
Улучшим код с помощью наших новых фабрик тайпгардов. Применив instanceOf
и someOf
, получаем куда более лаконичный и декларативный код c большей гарантией надежности и устойчивостью к изменениям:
const started$ = router.events.pipe(
filter(instanceOf(NavigationStart)),
);
const finished$ = router.events.pipe(
filter(
someOf(
instanceOf(NavigationEnd),
instanceOf(NavigationCancel),
instanceOf(NavigationError),
)
),
);
Заключение
Несмотря на всю мощь вывода типов, TS иногда требует явного описания механизмов проверки для сужения типов. Но создание пользовательских тайпгардов иногда влечет однотипный код и потерю типобезопасности. На примере is
, instanceOf
и someOf
мы рассмотрели подход с созданием обобщенных фабрик тайпгардов, который помогает решить эти проблемы. И пусть наша реализация не безупречна, она вполне подходит для большинства повседневных задач. Вы можете поиграть с фабриками из статьи в TypeScript Playground.
Если подключить фантазию, можно продолжить список возможных фабрик: hasProperty
, arrayOf
, equalsDeep
и так далее. А если пойти еще дальше, можно попробовать реализовать универсальную фабрику в духе:
// const isPosition: (x: unknown) => x is {
// position: 0 | [number, number],
// info: null | string | Info
// }
const isPosition = is({
position: is(0, [Number, Number])
info: is(null, String, instanceOf(Info))
});
Спасибо, что дочитали! Это моя первая статья на Хабре, поэтому я буду особенно рад комментариям и обратной связи.