write our own Utility Types. keyword infer

Problem

Imagine that we are writing a library for rendering UI components. The example is conditional.

The base abstract class of the UI component is given. At the input in the constructor, we necessarily receive the parameters and dependencies necessary for its operation. We will omit specific details about dependencies. We also require the implementation of the method from the heirs, which should return the component template. The template type is not important in this situation.

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

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

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

The main rendering class is also given. It is responsible for creating the UI component and inserting it into the DOM tree. We are only interested in the implementation of the method render, we omit the rest. This method takes as input the parent element, the component to render, and its parameters. In the method itself, the component’s dependencies are collected, it is created through the constructor, the template is received, and the template is inserted into the 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-дерево
	}

	...
}

We also have a couple of components that we will be rendering. Each one inherits from the base class of the component, describes their parameters with interfaces and implements methods for describing templates. Let’s drop the line.

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 {
		...
	}
}

This is what the method call site will look like render.

...

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

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

...

The problem is that there are no explicit contracts for using components. At any time, any of them will change the parameters and there will be no errors at the place of use. In the new rendering call places, when describing the passed parameters, there will be no hints on fields and their types. Complete rubbish…

VS code. Render Call Location for the Header Component
VS code. Render Call Location for the Alert Component

How to protect the team from this problem? The obvious answer is to type the parameters of the render method. How to do it? Let’s take a look at a few possible answers to this question.

Solution 1: Indexed Access Types

How does it work?

Imagine we have an interface MyInterface.

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

TypeScript allows you to extract property types from complex types by key. This is done in the same way as property values ​​are obtained from objects. Alas, it is impossible to get a type through a dot.

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

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

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

The same can be done with classes and types that describe an object (analogous to an interface). In general, if the type is composite, then the type of its part can be obtained from the key.

Solution

First, let’s add a utility type that will get the parameter type from the component:

  1. Restrict the types for the parameter T. Let’s leave the ability to transfer only class heirs UIComponent. This is done using the construct T extends UIComponent<unknown> in angle brackets;

  2. Get the required type by key params.

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

We will also improve the method render:

  1. Since the utility type only accepts descendants UIComponenthere you also need to restrict the type T. Everything is exactly the same as in the utility type;

  2. Since we now know that T is any inheritor of the class UIComponentyou can slightly change the parameter type component from Type<UIComponent<any>> on the Type<T>;

  3. And, of course, we will replace params: any on the 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, ...);

	...
}

Now the static analyzer knows which type for which component to pass to render as a parameter.

VS code. Render Call Location for Typed Header Component
VS code. Render Call Location for Typed Alert Component

Solution 2: Type inference. keyword infer

How does it work?

I want to start with an existing utility type Parameters<T>. This type accepts T function/method type and extracts from it the types of all its parameters in order into a tuple. I propose to take a look at an example.

We have a function.

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

She, in turn, has such a type.

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

// type MyFunctionType = typeof myFunction;

If we pass MyFunctionType in Parameters<T> how Tthen this whole construction will infer the type [number, boolean, MyInterface].

type MyFunctionParametersTuple = Parameters<MyFunctionType>

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

But how does this type work? Answer – via keyword infer.

This keyword allows you to pull types from conditional generic types. An example of a conditional generic type:

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

A little explanation. Type MyType calculates the resulting type depending on the passed parameter T. It works like a ternary operator если что-то ? то это : иначе это. In this case extends is the comparison operator.

If you look at the d.ts file, which contains Parameters<T>then you can see the following.

type Parameters<T extends (...args: any) => any> =
	T extends (...args: infer P) => any ? P :
	never;
  1. The use of a type is possible only with functions. This is what the design says T extends (...args: any) => any in angle brackets;

  2. To apply infera type check is generated via extendsso the construction is used T extends (...args: infer P) => any. It can be noted that infer P is substituted exactly for the type that needs to be deduced. The argument type is a tuple, so infer get it in P exactly the tuple of function parameters T. If we needed to infer the return type, then we would need to substitute infer P in place of the return type. This can be seen in the utility type declaration ReturnType<T>;

  3. Since the desired type is inferred in a new parameter Pit returns in the first branch;

  4. If T somehow turns out to be not a function, which means you can’t know for sure where to substitute the construction infer P, should return some other type. In this case never.

Here is another example, but if we want to know the type Twhich came to us from somewhere outside.

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

If I transfer to ObservableValueType<T> type Observable<number> how Tthen ObservableValueType<Observable<number>> print and return type number. All because during the check we set up infer V instead of the type we want to output.

Solution

Just like in the first solution, let’s add a utility type that will get the type of parameters from the component, but using the keyword infer.

  1. We limit Tas in the first solution;

  2. We register the conditional type. Let’s check that T is indeed the heir UIComponent. And in the same construction we substitute instead of unknown construction infer P;

  3. Since we know that in P contains the external type that we need, we can return it;

  4. If T is not an heir UIComponentreturn type never.

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

And, in the same way as in the first solution, we will finalize the method render.

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

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

	...
}

Now I’ll just show screenshots with errors. In the first case, we remove one of the fields, and in the second, we change the value of the property so that it is of an invalid type.

VS code. Place of render call for Header component with invalid data
VS code. Render Call Location for Alert Component with Invalid Data

Outcome

We have considered two variants of dynamic parameter typing.

In the first option, we wrote our own utility type by getting the property type from the parameter type passed to the method by key (Indexed Access Types).

In the second variant, we also wrote our own utility type, but using the construct infer Psubstituting it for a place of an unknown type.

In my opinion, it is better to use the option with inferbecause we do not bind our utility type to any specific field, only to an unknown type T.

Now, when changing the parameters of the component, we immediately find out which places in the code base are broken. The team does not have to suffer in search of places to use the changed component. Everyone is happy!

What do you think about the example situation? Write in the comments.

Links

Similar Posts

Leave a Reply Cancel reply