Going deeper with typescript advanced types

amplanetwork

Ampla Network

Posted on December 3, 2020

Going deeper with typescript advanced types

In this post we will see how we can use Typescript typing system to create a Mapper helper.

Let's imagine we have an object like this one :

interface IGraphicControl {
  width     : number;
  height    : number;
  alpha     : number;
  fillColor : string | number;

  drawRect(x: number, y: number, width: number, height: number): void;
  render(): void;
}
Enter fullscreen mode Exit fullscreen mode

Now if we need to set several properties we need to do the following.

const myGraphic = new Graphic();

myGraphic.width  = 100;
myGraphic.height = 100;
myGraphic.alpha  = 1;

myGraphic.fillColor = 0x00FF00;
myGraphic.drawRect(0,0,50,50);

myGraphic.fillColor = 0x0000FF;
myGraphic.drawRect(50,50,50,50);

myGraphic.render()
Enter fullscreen mode Exit fullscreen mode

We want to simplify the mapping a little bit so we can do this :

setTo(myGraphic, {
  width     : 100,
  height    : 100,
  alpha     : 1,
  fillColor : 0x00FF00,
  drawRect  : [0,0,50,50] // Call the function
})
Enter fullscreen mode Exit fullscreen mode

We want to be able to define all properties with the correct values, and call functions with parameters as tuples. But we want that for every object we pass as the first parameter, the second parameter provides the right intellisense.

To create such a function, we will have to extract all the information from the first parameter.

We will need to extract all properties and functions, and treat the functions as tuples of parameters, correctly typed.

Step 1

Create a type that will invalidate properties that do not correspond to the type you are looking for.

type ConditionalTypes<Base, Condition> =  {
  [Key in keyof Base]: Base[Key] extends Condition ? never : Key;
}
Enter fullscreen mode Exit fullscreen mode

So we create a type in the form of a hashMap whose keys are the properties of the Base type, and whose type will be either a string of the name of the key, or an impossibility of assignment.

type newFilteredType = ConditionalTypes<IGraphicControl, Function>;

// Will be the same as 
type newFilteredType = {
  width     : "width";
  height    : "height";
  alpha     : "alpha";
  fillColor : "fillColor";

  drawRect : never;
  render   : never;
}
Enter fullscreen mode Exit fullscreen mode

So why create a type whose properties are string values ?
Simply because now we can extract those types.

Step 2

We need to extract the valid keys, but it is not possible to list the keys we want to keep. Instead we can extract all property types of a type, excluding those of never type.

// We will change this
type ConditionalTypes<Base, Condition> = {
  [Key in keyof Base]: Base[Key] extends Condition ? never : Key;
}

// to 

type ConditionalTypes<Base, Condition> = {
  [Key in keyof Base]: Base[Key] extends Condition ? never : Key;
}[keyof Base]
Enter fullscreen mode Exit fullscreen mode

Now we can retreive all types excluding nerver types. The tricky part comes here, as each valid type is a string :-). We will retreive all valid names as string.

type newFilteredType = ConditionalTypes<IGraphicControl, Function>;

// Will be the same as 
type newFilteredType = "width" | "height" | "alpha" | "fillcolor";
Enter fullscreen mode Exit fullscreen mode

Step 3

Now we need to extract the real types of the selected keys.
We will use the Pick type.

// We will change this
type ConditionalTypes<Base, Condition> = {
  [Key in keyof Base]: Base[Key] extends Condition ? never : Key;
}[keyof Base]

// to 

type ConditionalTypes<Base, Condition> = Pick<Base, {
  [Key in keyof Base]: Base[Key] extends Condition ? never : Key;
}[keyof Base]>
Enter fullscreen mode Exit fullscreen mode

And then this will result in the following

type newFilteredType = ConditionalTypes<IGraphicControl, Function>;

// Will be the same as 
type newFilteredType = {
  width     : number;
  height    : number;
  alpha     : number;
  fillColor : number | string;
}
Enter fullscreen mode Exit fullscreen mode

Yessssss, we got it !!!

Step 4

We need now to get all fields that are not functions, and all that are functions to process them differently.

So let's change our type again

type ConditionalTypes<Base, Condition> = Pick<Base, {
  [Key in keyof Base]: Base[Key] extends Condition ? never : Key;
}[keyof Base]>

// to 

type ConditionalTypes<Base, Condition, Extract extends Boolean> =  Pick<Base, {
  [Key in keyof Base]: Extract extends true ? 
    Base[Key] extends Condition ? Key : never 
    : 
    Base[Key] extends Condition ? never : Key
}[keyof Base]>;
Enter fullscreen mode Exit fullscreen mode

We added an third type that extends boolean, so we will use it to define if we want to extract selected type, or exclude it.

Now we are able to get what we want.

type newFilteredType = ConditionalTypes<IGraphicControl, Function, false>;

// Will be the same as 
type newFilteredType = {
  width     : number;
  height    : number;
  alpha     : number;
  fillColor : string | number;
}

// AND
type newFilteredType = ConditionalTypes<IGraphicControl, Function, true>;

// Will be the same as 
type newFilteredType = {
  drawRect(x: number, y: number, width: number, height: number): void;
  render(): void;
}
Enter fullscreen mode Exit fullscreen mode

Step 5

We are now able to separate properties into two categories, functions and the remainder.

We need to rebuild a type whose functions will no longer be defined as functions, but as an array of typed parameters.

We will use the Parameters type, that can extract all parameter types and put them in a tuple.

type ParameterType<T> = Partial<
  ConditionalTypes<T, Function, false> // Properties that are not functions
  & 
  { 
    [K in keyof ConditionalTypes<T, Function, true>]: Parameters<ConditionalTypes<T, Function, true>[K]> // Tuple
  }
>;
Enter fullscreen mode Exit fullscreen mode

Step 6

The target prototype is

function setTo<T>(source: T, value: ParameterType<T>): void
Enter fullscreen mode Exit fullscreen mode

And to use it

setTo(myGraphic, {
  width     : 100,
  height    : 100,
  alpha     : 1,
  fillColor : 0x00FF00
});

setTo(myGraphic, {
  drawRect: [0,0,50,50]
}

setTo(myGraphic, {
  render: []
}
Enter fullscreen mode Exit fullscreen mode

We still need to do an extra call to render after because the render should not be called at the same time, but after. So it is not very usefull as is.

Final step

As a bonus, we will add a way to Chain several call without the need to pass the source as a parameter

function setTo<T>(source: T, value: ParameterType<T>) {
  for(const key in value) {
    if (key in source) {
      typeof source[key as keyof T] === "function" ? 
        (source[key as keyof T] as unknown as Function).apply(source, (value as unknown as any)[key])
        :
        source[key as keyof T] = (value as unknown as any)[key];
    }
  }
 return (nextValue: ParameterType<T>) => setTo(source, nextValue);
}
Enter fullscreen mode Exit fullscreen mode

We did it !

As a result, we can now do the following

setTo(myGraphic, {
  width     : 100,
  height    : 100,
  alpha     : 1,
  fillColor : 0x00FF00 
})({
  drawRect  : [0,0,50,50]
})({
  alpha     : 0.5,
  fillColor : 0xFFFF00,
})({
  drawRect  : [50,50,50,50]
})({
  render: [];
})
Enter fullscreen mode Exit fullscreen mode

For big declaration like animations, this can reduce the amount of code. This sample may not be the most accurate but it shows you how much powerful typescript can be.

On a day to day basis, you don't need to deal with advanced typing, but if you create helpers in libraries or frameworks, you can provide a very usefull intellisense and type constraint that will save developers a lot of time and debugging hours..

Enjoy !

💖 💪 🙅 🚩
amplanetwork
Ampla Network

Posted on December 3, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related