Йо, Хабр! Меня зовут Алексей Акулов. Я разрабатываю клиентскую часть продукта BIMeister.

Почти каждый сталкивался с типами Partial или Record. Там таких еще много, но суть в том, что они входят в ту самую группу Utility Types. Такие штуки представляют из себя разные преобразования одних типов в другие. Partial помечает все поля опциональными. Record отдает тип с бесконечным числом полей одного типа. Тут, вроде, понятно, но как писать собственные? Что такое infer? Как он может нам помочь?

Проблема

Представим, что мы пишем библиотеку для рендеринга UI компонентов. Пример условный.

Дан базовый абстрактный класс UI компонента. На вход в конструкторе обязательно получаем параметры и зависимости, нужные для его работы. Конкретные подробности о зависимостях опустим. Также требуем у наследников реализацию метода, который должен вернуть шаблон компонента. Тип шаблона в этой ситуации неважен.

abstract class UIComponent<P> {
  constructor(
    public readonly params: P,

    // Возможно получить только внути класса Renderer, 
    // который представлен далее по тексту
    protected readonly appState: AppState,
    ...
  ) {
    ...
  }

  // описание шаблона компонента,
  // который зависит от P, AppState и остальных параметров
  public abstract getTemplate(): SomeTemplateType;
}

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

type Type<T> = new (...args: any) => T;

class Renderer {
	...

	public render(
		parentElement: HTMLElement,
		component: Type<UIComponent<any>>,
		params: any
	): void {

		const appState: AppState = this.getAppState();
		... // другие зависимости

		const componentInstance: UIComponent<any> = new component(
			params,
			appState,
			...
		);

		const template: SomeTemplateType = componentInstance.getTemplate();

		...
		// какая-то логика вставки компонента в DOM-дерево
	}

	...
}

Также у нас есть пара компонентов, которые мы будем рендерить. Каждый наследуем от базового класса компонента, интерфейсами описываем их параметры и реализуем методы для описания шаблонов. Верстку опустим.

interface HeaderComponentParams {
	hasLogo: boolean;
	menuItems: MenuItem[];
}

class HeaderComponent extends UIComponent<HeaderComponentParams> {
	public getTemplate(): SomeTemplateType {
		...
	}
}
interface AlertComponentParams {
	hasCloseButton?: boolean;
	bodyText?: string;
	headerText?: string;
}

class AlertComponent extends UIComponent<AlertComponentParams> {
	public getTemplate(): SomeTemplateType {
		...
	}
}

Вот как будет выглядеть место вызова метода render.

...

renderer.render(
	appBodyElement,
	HeaderComponent,
	{ hasLogo: true, menuItems: [...] }
);

renderer.render(
	overlayHostElement,
	AlertComponent,
	{ bodyText: 'Some body text!' }
);

...

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

VS Code. Место вызова рендеринга для компонента Header
VS Code. Место вызова рендеринга для компонента Header
VS Code. Место вызова рендеринга для компонента Alert
VS Code. Место вызова рендеринга для компонента Alert

Как обезопасить команду от этой проблемы? Очевидный ответ – протипизировать параметры метода render. Как это сделать? Дальше разберем несколько вариантов ответов на этот вопрос.

Решение 1. Обращение к типам по ключу (Indexed Access Types)

Как работает?

Представим, что у нас есть интерфейс MyInterface.

interface MyInterface {
	myProperty1: number;
	myProperty2: string;
	myProperty3: MyOtherInterface;
}

TypeScript позволяет доставать типы свойств из сложных типов по ключу. Делается это так же, как из объектов достаются значения свойств. Увы, через точку тип получить нельзя.

type MyProperty1Type = MyInterface['myProperty1'];
// type MyProperty1Type = number;

type MyProperty2Type = MyInterface['myProperty2'];
// type MyProperty1Type = string;

type MyProperty3Type = MyInterface['myProperty3'];
// type MyProperty1Type = MyOtherInterface;

То же можно провернуть и с классами и с типами, которые описывают объект (аналог интерфейса). В общем, если тип составной, то тип его части можно получить по ключу.

Решение

Для начала давайте добавим utility-тип, который достанет из компонента тип параметров:

  1. Ограничим типы для параметра T. Оставим возможность передавать только наследников класса UIComponent. Это делается с помощью конструкции T extends UIComponent<unknown> в угловых скобках;

  2. Достанем нужный тип по ключу params.

type UIComponentParamsType<T extends UIComponent<unknown>> = T['params'];

Также доработаем метод render:

  1. Так как utility-тип принимает только наследников UIComponent, тут так же нужно ограничить тип T. Все в точности, как в utility-типе;

  2. Так как мы теперь знаем, что T это любой наследник класса UIComponent, можно немного изменить тип параметра component с Type<UIComponent<any>> на Type<T>;

  3. И, конечно же, заменим params: any на params: UIComponentParamsType<T>.

public render<T extends UIComponent<unknown>>(
	parentElement: HTMLElement,
	component: Type<T>,
	params: UIComponentParamsType<T>
): void {
	...

	const componentInstance: T = new component(params, appState, ...);

	...
}

Теперь статический анализатор знает, какой тип для какого компонента нужно передавать в render как параметр.

VS Code. Место вызова рендеринга для компонента Header с типизацией
VS Code. Место вызова рендеринга для компонента Header с типизацией
VS Code. Место вызова рендеринга для компонента Alert с типизацией
VS Code. Место вызова рендеринга для компонента Alert с типизацией

Решение 2. Вывод типов. Ключевое слово infer

Как работает?

Хочется начать с существующего utility-типа Parameters<T>. Данный тип принимает как T тип функции/метода и достает из него типы всех ее параметров по порядку в кортеж. Предлагаю разобрать на примере.

У нас есть функция.

function myFunction(
	param1: number,
	param2: boolean,
	param3: MyInterface
): void {
  // какое-то действие
}

Она, в свою очередь, имеет такой тип.

type MyFunctionType = (
	param1: number,
	param2: boolean,
	param3: MyInterface
) => void;

// type MyFunctionType = typeof myFunction;

Если мы передадим MyFunctionType в Parameters<T> как T, то вся эта конструкция выведет тип [number, boolean, MyInterface].

type MyFunctionParametersTuple = Parameters<MyFunctionType>

// type MyFunctionParametersTuple = [number, boolean, MyInterface];

Но как же этот тип работает? Ответ – через ключевое слово infer.

Данное ключевое слово позволяет вытягивать типы из условных дженериковых типов. Пример условного дженерикового типа:

type MyType<T> =
	T extends MyEnum.First ? number :
	T extends MyEnum.Second ? string :
	never;

Небольшое объяснение. Тип MyType вычисляет итоговый тип в зависимости от переданного параметра T. Это работает по принципу тернарного оператора если что-то ? то это : иначе это. В данном случае extends – это оператор сравнения.

Если посмотреть d.ts файл, в котором лежит Parameters<T>, то там можно увидеть следующее.

type Parameters<T extends (...args: any) => any> =
	T extends (...args: infer P) => any ? P :
	never;
  1. Применение типа возможно только с функциями. Об этом говорит конструкция T extends (...args: any) => any в угловых скобках;

  2. Чтобы применить infer, создается проверка типа через extends, поэтому используется конструкция T extends (...args: infer P) => any. Можно обратить внимание, что infer P подставляется именно вместо того типа, который необходимо вывести. Тип аргументов – это кортеж, поэтому infer достанет в P именно кортеж параметров функции T. Если бы нам понадобилось вывести возвращаемый тип, то нужно было бы подставить infer P на место возвращаемого типа. Это можно увидеть в декларации utility-типа ReturnType<T>;

  3. Так как нужный тип выводится в новый параметр P, он возвращается в первой ветке;

  4. В случае если T каким-то образом оказывается не функцией, а значит нельзя наверняка знать куда подставлять конструкцию infer P, следует вернуть какой-то другой тип. В данном случае never.

Вот еще пример, но если мы хотим узнать тип T, который нам пришел откуда-то извне.

type ObservableValueType<T extends Observable<unknown>> =
	T extends Observable<infer V> ? V : never;

Если я передам в ObservableValueType<T> тип Observable<number> как T, то ObservableValueType<Observable<number>> выведет и вернет тип number. Все потому, что во время проверки мы подставили infer V вместо типа, который хотим вывести.

Решение

Так же, как в первом решении, добавим utility-тип, который достанет из компонента тип параметров, но с помощью ключевого слова infer.

  1. Ограничиваем T, как в первом решении;

  2. Прописываем условный тип. Проверим, что T действительно является наследником UIComponent. И в этой же конструкции подставим вместо unknown конструкцию infer P;

  3. Так как мы знаем, что в P содержится внешний тип, который нам нужен, можем его вернуть;

  4. В случае, если T не является наследником UIComponent, возвращаем тип never.

type UIComponentParamsType<T extends UIComponent<unknown>> =
	T extends UIComponent<infer P> ? P : never;

И, точно так же, как в первом решении, доработаем метод render.

public render<T extends UIComponent<unknown>>(
	parentElement: HTMLElement,
	component: Type<T>,
	params: UIComponentParamsType<T>
): void {
	...

	const componentInstance: T = new component(params, appState, ...);

	...
}

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

VS Code. Место вызова рендеринга для компонента Header c невалидными данными
VS Code. Место вызова рендеринга для компонента Header c невалидными данными
VS Code. Место вызова рендеринга для компонента Alert с невалидными данными
VS Code. Место вызова рендеринга для компонента Alert с невалидными данными

Итог

Мы рассмотрели два варианта динамической типизации параметров.

В первом варианте написали свой utility-тип с помощью получения типа свойства из типа параметра, переданного в метод, по ключу (Indexed Access Types).

Во втором варианте тоже написали свой utility-тип, но с использованием конструкции infer P, подставив ее на место неизвестного типа.

На мой взгляд, лучше использовать вариант с infer, потому что мы не завязываем свой utility-тип на какое-то конкретное поле, лишь на неизвестный тип T.

Теперь при изменении параметров компонента мы сразу узнаем, какие места в кодовой базе поломались. Команде не нужно страдать в поисках мест использования изменившегося компонента. Все счастливы!

Что думаете по поводу ситуации из примера? Пишите в комментах.

Ссылки

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


  1. Boronya
    06.04.2022 16:36

    Достаточно объемная и полезная статья


  1. amberv
    07.04.2022 07:49

    Я просто оставлю это здесь для всех, кто хочет прокачать свои навыки в TS: https://github.com/type-challenges/type-challenges