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…
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:
-
Restrict the types for the parameter
T
. Let’s leave the ability to transfer only class heirsUIComponent
. This is done using the constructT extends UIComponent<unknown>
in angle brackets; -
Get the required type by key
params
.
type UIComponentParamsType<T extends UIComponent<unknown>> = T['params'];
We will also improve the method render
:
-
Since the utility type only accepts descendants
UIComponent
here you also need to restrict the typeT
. Everything is exactly the same as in the utility type; -
Since we now know that
T
is any inheritor of the classUIComponent
you can slightly change the parameter typecomponent
fromType<UIComponent<any>>
on theType<T>
; -
And, of course, we will replace
params: any
on theparams: 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.
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 T
then 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;
-
The use of a type is possible only with functions. This is what the design says
T extends (...args: any) => any
in angle brackets; -
To apply
infer
a type check is generated viaextends
so the construction is usedT extends (...args: infer P) => any
. It can be noted thatinfer P
is substituted exactly for the type that needs to be deduced. The argument type is a tuple, soinfer
get it inP
exactly the tuple of function parametersT
. If we needed to infer the return type, then we would need to substituteinfer P
in place of the return type. This can be seen in the utility type declarationReturnType<T>
; -
Since the desired type is inferred in a new parameter
P
it returns in the first branch; -
If
T
somehow turns out to be not a function, which means you can’t know for sure where to substitute the constructioninfer P
, should return some other type. In this casenever
.
Here is another example, but if we want to know the type T
which 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 T
then 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
.
-
We limit
T
as in the first solution; -
We register the conditional type. Let’s check that
T
is indeed the heirUIComponent
. And in the same construction we substitute instead ofunknown
constructioninfer P;
-
Since we know that in
P
contains the external type that we need, we can return it; -
If
T
is not an heirUIComponent
return typenever
.
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.
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 P
substituting it for a place of an unknown type.
In my opinion, it is better to use the option with infer
because 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.