Gaurab Paul

Polyglot software developer & consultant passionate about web development, distributed systems and open source technologies

Support my blog and open-source work

Tags

Simplifying generics-heavy typescript code using Container Interfaces, Extractor Types and Companion Namespaces
Posted  5 years ago

Writing generics-heavy code code in Typescript can sometimes be arduous, especially because typescript doesn’t facilitate higher kinded types at language level.

So, a function that accepts multiple arguments of generic types often has to accept type parameters of all these generic types in order to retain type safety:

This can get cumbersome fast, especially when the parameterized types have constraints on the type parameters because now these constraints also have to be replicated:

If the constraint T1 extends T4, T5 is not replicated over to func it will result in a compilation error. The above example illustrates a function, but of course the same idea carries over to consumer interfaces etc. as well.

This is a chore when the combination of the same types (here T1, T2, T3) with identical type constraints have to be used in multiple places.

With the introduction of conditional types in typescript@2.8 we now have the ability to extract out generic type parameters from parameterized types. We can use this to our advantage to reduce the redundancy in scenarios like above by defining Container Types.

These container types are not intended to be used by themsevles. So although in some cases container types may coincide with application domain types, often we would not have any objects of these types in runtime.

Example:

Now we can define an unparameterized derived type which we can use instead of the three type parameters:

We would usually want this to be the minimally lax type that satisfies all the constraints. For example, in above case we could use any as the first type parameter but we used T4 (assuming T4 is some concrete type) for better type safety.

But now, we need to extract the member types from FuncParamsU for using them in places where the member types are expected.

Conditional types to the rescue:

These Extractor Types (sometimes called Type Operators in a more generic contexts) serve to pull out specific generic parameters from the Container Type.

Now we can redefine our func function as follows:

This has become somewhat longer, but is more advantageous in that if we need to modify one of the involved constraints, we would have to modify only the FuncParams interface.

Note that when we need to use FuncParams, we also need to pull in all these extractor types.

Another helpful pattern here is to define a Companion Namespace for every Container Type which contains all the associated type operators:

The use of the same name for the Container Interface and its Companion Namespace is purely a matter of convention, but it helpful for logical correlation that FuncParams.T1 is the extractor type to pull out T1 from FuncParams<T1, T2, T3>.

Another side benefit is that only a single import is required to import the Container Type as well as the Companion Namespace.

So now our func implementation can be: