DEV Community

ClementVidal
ClementVidal

Posted on

Use Typescript mapped types to "Promisify" interfaces

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

And here is how to use it:

type DataServiceWithoutAttributes = FilterOutAttributes<DataService>;
Enter fullscreen mode Exit fullscreen mode

Let's break this down:

type FilterOutAttributes<Base>
Enter fullscreen mode Exit fullscreen mode

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]: ...
}
Enter fullscreen mode Exit fullscreen mode

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];
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Or using ternary condition:

Base[Key] is a function ? It's a function : It's not a function;
Enter fullscreen mode Exit fullscreen mode

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 ? ... : ...
Enter fullscreen mode Exit fullscreen mode

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 Baseobject.

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>;
}
Enter fullscreen mode Exit fullscreen mode

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>>;
Enter fullscreen mode Exit fullscreen mode

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>>

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]>;
}
Enter fullscreen mode Exit fullscreen mode

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>>; 
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (0)