Monday, August 22, 2022

TypeScript's Conditional Type Operator example

We all like good examples, examples which, when seen, make us grasp new ideas easier. I believe the notion of Conditional Types in TypeScript is often demonstrated in a way that makes you wonder what's the real purpose of it. Let's just have yet another example that could be useful here.

Let's start with a function type, it could be a type of a custom function or a built in function.

// a custom function
function foo( s: string, n: number, b: boolean | symbol) : void {

}
type FooType = typeof foo;
// FooType = (s: string, n: number, b: boolean | symbol) => void

// the built in function, Array.prototype.slice
type ArraySliceType = typeof Array['prototype']['slice'];
// ArraySliceType = (start?: number | undefined, end?: number | undefined) => any[]
The two function types have a different number of parameters but with the TypeScript's type system, we can write an auxiliary type that just picks a type of specific parameter.

This is where the actual example begins. The initial part of the example is based on a code presented in Programming TypeScript by B. Cherny. Let's start with a type that picks a parameter that comes, like, second on the list of parameters.

type SecondArg<F> = F extends (a: any, b: infer B, ...c: any ) => any ? B : never;
Let's comment that. The SecondArg type is a generic type that expects a single generic parameter, F. This single generic parameter type is checked to be a function (the F extends ... part) and depending on the test, the conditional operator either returns an inferred type of the second argument (infer B) or fails (never).

Let's test this

type SecondArgString = SecondArg<string>;
// never
type SecondArgFoo = SecondArg<FooType>;
// number
type SecondArgArraySlice = SecondArg<ArraySliceType>;
// number | undefined
This works great and to me - it's enough to demonstrate how useful the ? : conditional operator is. Using this technique we can pick a type of a specific argument of specific function type, which, as far as I know, is not possible in languages like, say, C# or Java. In C#, if there's a function and the second argument of the function is, say, int and you have to declare a variable of this type, you have to know this type in advance to even start writing code.

But, this example can easily be pushed forward, why just pick a specific, second parameter? Why not write a generic type that picks the argument we want, first, second, third? Let's go beyond the original example from the book.

The question is, can numbers (parameter indexes) can be arguments of generic types?

The answer is, sure, it's TypeScript, literals can be types. Remember the part of the TypeScript tutorial when you learn that a string 'foo' can also be a type that is just a subtype of string? Well, 5 can be a type that's just a subtype of number.

The only small technical issue is that our new generic type needs two generic arguments and we need constraints on both of them which the ? : operator doesn't support directly (or I don't know how to do it :).

The first approach involves nesting the ? : operator so that the nested part introduces the constraint on the second argument.

type NThArg<F, N> = 
    F extends (...c: infer C) => any 
    ? (N extends number ? C[N] : never ) 
    : never;  
  
Please take a while to compare this new type to the previous one. As you can see, the parameter list is expressed in a more concise way, we don't need to introduce specific parameters, instead we hide them all under the spread (...) operator. But the other generic parameter, N is constrained to be a number (the N extends a number part) so that it can be used to just index the signature type (the C[N]) part.

If nesting the conditional type operator bothers you as much as it bothers me, there's another version

type NThArg2<F, N extends number> = 
    F extends (...c: infer C) => any 
    ? C[N]
    : never;  
  
This time there's no nesting of the conditional operator. Instead, the constraint on N is expressed directly in type signature which works here as the constraint on N doesn't really invole anything fancy (no need for conditionals, infer, etc.)

Both operators work, please take your time to play around

type FooTypeNth10  = NThArg<FooType, 0>
// string
type FooTypeNth11  = NThArg<FooType, 1>
// number
type NThArgSlice = NThArg<ArraySliceType, 1>;
// number | undefined

type FooTypeNth2  = NThArg2<FooType, 1>
// number
type NThArgSlice2 = NThArg2<ArraySliceType, 1>;
// number | undefined  
  

And just by the way, did you know that TypeScript's type system is Turing Complete (please also take a look here and here and here)? This basically means that we could write actual programs using types only and results would be computed by the compiler during compilation. Please check the linked article to find few possible ways this could be used (and no, it's not quite useful, you wouldn't like to write code that runs on your type system).

Happy coding.

No comments: