Привет, Хабр! Меня зовут Сергей Соловьев, я разрабатываю интерфейсы международных платежей в Тинькофф Бизнесе. Уверен, многие хотят писать надежный, поддерживаемый, но при этом лаконичный код. Как найти баланс?

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<T> {
  filter<S extends T>(
    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<T extends Primitive>(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<State>();

// const ok$: Observable<State.Ok>;
const ok$ = state$.pipe(filter(is(State.Ok)));

Обработка граничных случаев

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

// const only2: string[];
const only2 = ['a', 'b', 'c'].filter(is(2));

Поскольку сконструированный фабрикой тайпгард не совместим с тайпгардом для значений из массива, то применяется обычная перегрузка метода filter. В итоге тип отфильтрованного массива будет совпадать с исходным, хотя еще на этапе компиляции мы достоверно знаем, что в результате фильтрации получится пустой массив (формально: []). Поскольку такая фильтрация не имеет смысла, попробуем запретить ее на уровне типов, чтобы уберечь незадачливого программиста от ошибки по невнимательности:

function is<T extends Primitive & S, S>(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<T = unknown, P extends unknown[] = never> {
    new(...args: P): T
}

По умолчанию мы используем never для списка параметров. Так мы гарантируем, что не сможем вызвать конструктор, если тип списка параметров конструктора P не указан явно. А теперь попробуем реализовать обобщенную фабрику:

export function instanceOf<T extends S, S>(clazz: Constructor<T>) {
    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<Event> и стоит задача отфильтровать событие в потоке. Теперь благодаря фабрике instanceOf мы можем легко отфильтровать событие. Но что, если нам требуется отфильтровать не одно, а несколько событий из общего числа?

Именно такая задача стояла недавно передо мной, что и привело к написанию этой статьи. Пошарив по репозиторию, я обнаружил в нем несколько объявлений тайпгардов типа (e: Event) => e is Event1 | Event2, которые фильтруют события попарно. Подходящего мне тайпгарда среди них не было… Можно было просто создать еще один, но это решение несло бы все те проблемы, которые мы разбирали в проверке на равенство значений. Поэтому сразу же появилась идея решить эту задачу в общем виде. В итоге я реализовал фабрику для комбинирования тайпгардов.

Разберем решение. Для удобства введем псевдоним для типа функции-тайпгарда:

type Guard<P = any, T extends P = P> = (x: P) => x is T;

Применение any вместо never в качестве типа по умолчанию — вынужденная необходимость из-за ограничения T extends P, вытекающего из природы тайпгарда. В остальных случаях правильнее использовать never для типа по умолчанию аргументов обобщенных типов функций.

Реализуем саму фабрику someOf:

function someOf<T extends Guard[]>(...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 extends Guard[]> =
  T[number] extends Guard<infer P, infer R>
    ? (x: P) => x is R
    : never;

function someOf<T extends Guard[]>(...guards: T) {
    return (x => guards.some(guard => guard(x))) as SomeOf<T>;
}

Проверим, какой тип теперь на выходе фабрики:

// const is2or3: (x: unknown) => x is 2 | 3
const is2or3 = someOf(is(2), is(3));

Вернемся к изначальной задаче:

const events$ = new Subject<Event>();

// const filtered$: Observable<Event1 | Event2>
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))
});

Спасибо, что дочитали! Это моя первая статья на Хабре, поэтому я буду особенно рад комментариям и обратной связи.

Комментарии (4)


  1. Alexandroppolus
    18.04.2024 19:55
    +4

    Интересная статья, спасибо.

    Применение any вместо never в качестве типа по умолчанию — вынужденная необходимость из-за ограничения T extends P, вытекающего из природы тайпгарда

    можно заменить на unknown

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

    Это стоит сделать, потому что прямо сейчас добавление "лишнего" пункта в someOf молча ломает типизацию, и фильтр перестает работать в режиме тайпгарда. Если в самом последнем примере добавить в копилку, допустим, is(2), то filtered будет Event[]

    Допилил этот кейс, небольшие изменения в строках 41-48


    1. SolovevSerg Автор
      18.04.2024 19:55

      можно заменить на unknown

      В данном случае заменить на unknown нельзя в силу контравариантности параметров функции. При P = unknown тип Guard не будет включать все тайпгарды, тип аргумента которых уже, чем unknown. Проиллюстрирую на примере:

      type Guard<P = unknown, T extends P = P> = (x: P) => x is T;
      
      // ✅ Ok
      const guard1: Guard = (x: unknown): x is 'a' => true;
      
      // ❌ Type '(x: string) => x is "a"' is not assignable to type 'Guard<unknown, unknown>'.
      const guard2: Guard = (x: string): x is 'a' => true;

      Ссылка на Playground


  1. TommyG
    18.04.2024 19:55

    Было бы круто, на каком-нибудь сайте, собрать примеры самых распростанённых типов отсортированных по категориям и с описанием сценариев применения и типичные тайпгарды для них.

    Потом можно будет обернуть в extension для vs code, чтобы сразу можно было бы происпользовать. Хотя конечно с появлением копайлотов и т.п., это становится всё менее и менее релевантно.