DEV Community

Cover image for The power of advanced typing in Typescript
Ampla Network
Ampla Network

Posted on

The power of advanced typing in Typescript

When I started working on version 3 of S-Flux, I really wanted to facilitate the use of the Application Stores and the methods to interact with them.

To do this, I wanted to leverage the power of advanced Typescript typing.

The inputs

Let's take a quick look at the inputs.

export default Flux.registerStore({
  id: "Counter",
  async init(): CounterState { 
    return { counter: 0 };
  },
  actions : _actions,
  events  : _events
});
Enter fullscreen mode Exit fullscreen mode

We define a unique Id, an init function to initialize the state, some actions and some events.

An action is an async method whose first parameter is the Payload.

// This parameter is not a real parameter :-)
async setCounter(this: TThis, payload: { counter: number }) { 
  /* ... */
  return _events.ForceCounter; // Return specific event 
}
Enter fullscreen mode Exit fullscreen mode

An event is a key/value pair defining an event that can be returned by a store with a specific subscription.

The target output

Now we need to see what we want as an output.

{
  getState() : CounterState ;
  id         : string       ;
  events: {
    All          : string ;
    ForceCounter : string ;
  },
  subscribeTo: {
    All          : ((state: CounterState) => void) => { off: () => void };
    ForceCounter : ((state: CounterState) => void) => { off: () => void };
  },
  actions: {
    increase   : () => void                             ;
    decrease   : () => void                             ;
    setCounter : (payload: { counter: number }) => void ;
  }
}
Enter fullscreen mode Exit fullscreen mode

In this example, the store wrapper will expose new methods based on the input types.

Events represents the same events as in the input, but for each event a corresponding method is generated in the subscribeTo object to register to the event when emitted by the store, plus a 'All' handler to catch all events.

Same for the actions object. For each action handler that has been registered, the counterpart method should be exposed to call the handler with the correct payload.

With this approach we will avoid the need to create manually some action helpers for each possible action.

The problem

If we create the wrapper manually, everything will be typed correctly, but the point is that the code will be created dynamically in the registerStore function.

So the return type will be any for the State, the actions and events objects. That's why we need to use the advance typing system to provide a fully typed object with all methods even for code that will be dynamically produced.

It is exactly for this kind of feature that the use of typescript rather than javascript seems obvious to me.

Preparing the types

Let's see the input type as it is defined in the registerStore method signature :

export type TStoreDefinition<S extends (...args: any[]) => any, T extends {[key: string]: (...args: any[]) => any}, U> = {
  id              ?: string                      ;
  localActions    ?: boolean                     ;
  actions          : T                           ;
  events          ?: U                           ;
  mappedActions   ?: { [key: string] : string; } ;
  init             : S                           ;
  dispatchHandler ?:(this: TBaseStore<ReturnType<S>>, payload: any, For? : TAwaitFor) => Promise<void | null | string | string[]> ;
  nextState       ?: (newState: Partial<ReturnType<S>>, mergeToPreviousState?: boolean) => void                                   ;
}
Enter fullscreen mode Exit fullscreen mode

We need to infer types from actions, events, and init.

  • actions contains keys that we want to extract
  • events contains keys that we want to extract
  • init contains a return type equal to the store State, so we need to extract it too.

To allow typescript to infer those types and to work with them, we need to dedicate a type for actions events and init, and to ensure that the input is correct, we add some constraint when declaring them.

Generating the types dynamically

Let's see how to expose the output methods of the actions object from the input actions field :-)

Extracting the field type.

We can use the PropType type to get a specific field type.

type TActions = PropType<typeof def, "actions">;
Enter fullscreen mode Exit fullscreen mode

PropType type is itself declared as follow :

export type PropType<TObj, TProp extends keyof TObj> = TObj[TProp];
Enter fullscreen mode Exit fullscreen mode

When declaring 'TProp extends keyof TObj', we will get all real keys of TObj, that's how we will have all typing for keys we don't know yet.

Now 'TActions' is of type T with the constraint defined in the signature

T extends {[key: string]: (...args: any[]) => any}
Enter fullscreen mode Exit fullscreen mode

TActions is infered, so it is not only a hashmap with a key of type string and a value as a method, it contains the correct keys declared in the input. That's the important part. Each key is fully declared and can be used.

Creating the target type

We need to create the target type that will expose the same keys but with diffrent values.

export type TActionExtention<T, U> = {
  [P in keyof T]: 
  ((this: TBaseStore<U>, payload: any) => Promise<void | null | string | string[]>) | 
  ((this: TBaseStore<U>, payload: any, For: TAwaitFor) => Promise<void | null | string | string[]>)
};
Enter fullscreen mode Exit fullscreen mode

[P in keyof T] ensures that every named key in T will have some constraints defined just after.

So in our case, every value of each key will be of one of the 2 types below.

  • ((this: TBaseStore<U>, payload: any) => Promise<void | null | string | string[]>)
  • ((this: TBaseStore<U>, payload: any, For: TAwaitFor) => Promise<void | null | string | string[]>)

We need to iterate on input keys to output the new defined types

const _actions = {} as {
  [P in keyof TActions] :  /* ... */
};
Enter fullscreen mode Exit fullscreen mode

_action has the same keys as TActions, and TAction is a type infered from the actions field in the input.

Conditional typing

We need to create a method type that will have as first parameter the correct payload. So we have to extract the payload type from the first parameter of the input method.

We can extract it like that :

PropType<Parameters<TActions[P]> , 0>
Enter fullscreen mode Exit fullscreen mode

Parameter will extract the parameters from TActions[P] as a hashmap where the key is the parameter index, and the value the parameter type itself.

PropType<..., 0> will extract the property type for the field named 0.

So the definition can now be

const _actions = {} as {
    [P in keyof TActions] :  (payload: PropType<Parameters<TActions[P]> , 0>) => void
  };
Enter fullscreen mode Exit fullscreen mode

Yes !!! but wait a minute... The input method can have a payload.... or not, so this parameter can be undefined.

This is when the Wouaou effect occurs. You can define types for something you don't know... and you can even test the type to add a condition... It is just amazing.

So the final type will look like this

const _actions = {} as {
    [P in keyof TActions] :  PropType<Parameters<TActions[P]> , 0> extends undefined ?
    (() => void) 
    :
    ((payload: PropType<Parameters<TActions[P]> , 0>) => void) 
  };
Enter fullscreen mode Exit fullscreen mode

And now we have a deal ! The result is a fully type object defining all methods we need with strongly types signatures to avoid syntax / typing errors at runtime.

That is why I love typescript so much :-)

And to finish, we want to expose a getState method that will return a value equals to the store state. We never defined directly a store state, but we have defined an init function whose return type is the one we need.

So to extract our store state we will do the following

type TState = ReturnType<PropType<typeof def, "init">> ;
Enter fullscreen mode Exit fullscreen mode

In 3 steps, we will get the def type, then extract the init field, which is a method, then its return type.

Conclusion

I hope this post will help you to see how powerfull Typescript's typing is.

If you want to know more you can check the Typescript documentation about advanced types : Link here

To see the source code of the example you can check S-Flux here

Enjoy.

Top comments (4)

Collapse
 
amplanetwork profile image
Ampla Network

As @tobiasnickel said, yes I do agree, it looks like it is complex, but you never need to use advanced typing on a day to day basis :-)

In my case, I was rewriting a library to simplify the use for others. That's why having powerfull features allow this kind of behaviour when using the result.

Collapse
 
mdhesari profile image
Mohammad Fazel

it's too complex isn't it?!

Collapse
 
bias profile image
Tobias Nickel

I think most of the very complex types only need to be used by framework creators and in meta programming.

And with version 4 and version 4.1, it has even more awsome capabilities , you can see my latest post.

I think the future with typescript will be bright, and you will receive the benefits in your IDE, even when codeing in JS.

Collapse
 
mdhesari profile image
Mohammad Fazel

That sounds great.

Thanks for your efforts