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:
1 |
function func<T1, T2, T3>(p1: W1<T1>, p2: W2<T2>): W3<T3> {} |
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:
1 2 3 4 5 |
interface W1<T1 extends T4, T5> { t1: T1; } function func<T1 extends T4, T5>(p1: W1<T1>, p2: W2<T2>): W3<T3> {} |
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:
1 2 3 4 5 6 7 8 9 10 11 |
interface FuncParams< T1 extends T4, T2, T3 > { // These variables are present just so to prevent the tooling // from nagging us to remove unused types _t1: T2 _t2: T2, _t3: T3 } |
Now we can define an unparameterized derived type which we can use instead of the three type parameters:
1 |
interface FuncParamsU extends FuncParams<T4, any, any> {} |
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:
1 2 3 |
type FuncParamsT1<T extends FuncParamsU> = T extends FuncParams<infer T1, any, any> ? T1 : never; type FuncParamsT2<T extends FuncParamsU> = T extends FuncParams<any, infer T2, any> ? T2 : never; type FuncParamsT3<T extends FuncParamsU> = T extends FuncParams<any, any, infer T3> ? T3 : never |
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:
1 |
function func<T extends FuncParamsU>(p1: W1<FuncParamsT1<T>>, p2: W2<FuncParamsT2<T>>): W3<FuncParamsT3<T>> {} |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// FuncParams.ts export interface FuncParams< T1 extends T4, T2, T3 > { // These variables are present just so to prevent the tooling // from nagging us to remove unused types _t1: T2 _t2: T2, _t3: T3 } export namespace FuncParams { export interface Zilch extends FuncParams<number, any, any> {} export type T1<T extends Zilch> = T extends FuncParams<infer T1, any, any> ? T1 : never; export type T2<T extends Zilch> = T extends FuncParams<any, infer T2, any> ? T2 : never; export type T3<T extends Zilch> = T extends FuncParams<any, any, infer T3> ? T3 : never } |
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.
1 |
import {FuncParams} from "./FuncParams"; // Imports both FuncParams interface as well as FuncParams namespace |
So now our func
implementation can be:
1 2 3 4 5 6 |
import {FuncParams} from "./FuncParams"; function func<T extends FuncParams.Zilch>( p1: W1<FuncParams.T1<T>>, p2: W2<FuncParams.T2<T>> ): W3<FuncParams.T3<T> {} |