Use Typescript mapped types to "Promisify" interfaces
ClementVidal
Posted on April 13, 2021
Intro
Imagine you're specifying an interface in Typescript with a bunch of methods (sync and async) and attributes, randomly, this one:
// This is the "Original interface" we'll refer to during this article.
interface DataService {
cache: Map<string, Data>;
invalidateCache();
processData( data: Data ): Promise<Result>;
compressData( data: Data ): Result;
}
Now imagine again that you need to derive it to another interface that will only expose it's methods:
interface DataService {
invalidateCache(): void;
processData( data: Data ): Promise<Result>;
compressData( data: Data ): Result;
}
Or to another one that will convert all sync method to async:
interface DataService {
invalidateCache(): Promise<void>;
processData( data: Data ): Promise<Result>;
compressData( data: Data ): Promise<Result>;
}
Let's see how we can do that using Typescript Mapped types
Filter out attributes from an interface:
First thing we want to do is to convert the "DataService" interface to this one:
interface DataServiceWithoutAttributes {
invalidateCache();
processData( data: Data ): Promise<Result>;
compressData( data: Data ): Result;
}
As you can see the only thing that changes between the original version and the "remapped one" is that the cache
attribute is gone.
Here is the Mapped type
used to do that:
type FilterOutAttributes<Base> = {
[Key in keyof Base]: Base[Key] extends (...any) => any ? Base[Key] : never;
}
And here is how to use it:
type DataServiceWithoutAttributes = FilterOutAttributes<DataService>;
Let's break this down:
type FilterOutAttributes<Base>
This declares a mapped type named FilterOutAttributes
that takes one generic parameter Base
.
We'll use it to provide the "original" interface that we want to remap.
{
[Key in keyof Base]: ...
}
The keyof
operator will return a list of all the keys available in Base and the in
operator will iterate over that list and store each value in Key
This is like writing the following loop:
const Keys = ["cache", "invalidateCache", "processData", "compressData"];
for( let i=0; i<Keys.length; i++ ) {
const Key = Keys[i];
}
Now, for each key, we need to determine if it will be present in the mapped type or not. (or in our case, if the key matches a function in the Base interface)
To do so we need a way to express condition:
if( Base[Key] is a function ) {
It's a function
} else {
It's not a function
}
Or using ternary condition:
Base[Key] is a function ? It's a function : It's not a function;
Well, turns out we can express that condition using [Conditional types] in typescript:(https://www.typescriptlang.org/docs/handbook/2/conditional-types.html)
Base[Key] extends (...any) => any ? ... : ...
See! This is a ternary expression and the left side is the test expression:
If Base[Key]
extends any kind of function ( (...any) => any
) then the condition is true.
Thinks of extends as a mechanism to constraint the parameter type to match what we want, you can mentally replace it by "can be safely converted into". So in our case, we could read that as: If
Base[Key]
can be safely converted into(...any) => any
then expression is true
So if the condition is true
, we return Base[Key]
from the ternary expression, which corresponds to the type of the attribute Key
in the Base
object.
And if the condition is false
, we return the keyword never
which will discard the current Key
from the mapped type.
Convert sync methods to async methods:
What we want to do is to convert the "DataServiceWithoutAttributes" interface to this one:
interface DataServicePromisified {
invalidateCache(): Promise<void>;
processData( data: Data ): Promise<Result>;
compressData( data: Data ): Promise<Result>;
}
Now, each method returns a "Promisified" version of the original:
The return value is kept but wrapped inside a promise.
Before going into the detail of this mapped type, let's introduce two builtin mapped type provided by Typescript:
-
ReturnType<Base>
If Base is a function, this will return the type of the return value of that function -
Parameters<Base>
If Base is a function, this will return the type of every parameter of that function as a tuple
Using those 2 primitives, we'll build a third one: PromisifyFunction<Base>
.
This mapped type will convert a sync function into an async one by wrapping its return value inside a Promise.
type PromisifyFunction<Function extends (...any) => any> =
(...args: Parameters<Function>) => Promise<ReturnType<Function>>;
So:
- If the
Function
parameter is a function:extends (...any) => any
- Return a function that:
- take the same set of parameters:
(...args: Parameters<Function>)
- Wrap the return type inside a promise:
Promise<ReturnType<Function>>
- take the same set of parameters:
Ok, so now that have our 3 primitives let's have a look at the implementation of the PromisifyObject<Base>
mapped type:
type PromisifyObject<Base extends { [key: string]: (...any) => any }> = {
[Key in keyof Base]: ReturnType<Base[Key]> extends Promise<any> ?
Base[Key] :
PromisifyFunction<Base[Key]>;
}
Now, this should make sense for you as we are following the same pattern as in the previous chapter:
- We remap each key of the input parameter:
[Key in keyof Base]
- For each key we use a ternary operator to decide what to do:
ReturnType<Base[Key]> extends Promise<any> ? ... : ...
- If the return value of the current key in the input parameter is a Promise:
ReturnType<Base[Key]> extends Promise<any>
- Then we return that the same type
- Otherwise use the
PromisifyFunction<Base>
defined earlier to convert this sync function into an async one.
And here we are!
Now we can combine our two custom mapped type to "Promisify" any kind of interface:
type DataServicePromisified = PromisifyObject<FilterOutAttributes<DataService>>;
The syntax is not the easiest to read, but once you get the things with the extends mechanism to apply constraint, and how to use it within a ternary operator, understanding such code is much easier :)
Now you may ask:
Okay, that's funny, but at which point in my day-to-day life is this thing useful?
Weel, check out my previous article for an example of where that could help you!
I hope you enjoy reading this article and that it was useful for you ;)
Happy (typed) coding!
Posted on April 13, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024