TypeScript 2.8's conditional types can be used to create compile-time inference assertions, which can be used to write tests that verify the behavior of TypeScript's inference on your API.

This is a very powerful tool for improving the usability of your API. To demonstrate, let's imagine that we are building a "pluck" function:

function pluck < T, K extends keyof T >( keys: K[], obj: T ): Partial < T > { const accum: Partial<T> = {}; for ( let key of keys) { accum[key] = obj[key]; } return accum; }

While this may look like a perfectly good type signature and implementation, when we consider the usability of the returned value's type, there are going to be some surprises—especially in TypeScript's --strict mode.

For this example, let's assume we have the following interface:

interface MyStruct { str: string ; num: number ; bool: boolean ; }

If we use this naive version of pluck , we'll see that there are some unexpected consequences of type inference.

let obj: MyStruct = { str: 'hello' , num: 123 , bool: true , }; let val = pluck([ 'str' , 'num' ], obj); type WhatIsStr = typeof val[ 'str' ]; type WhatIsNum = typeof val[ 'num' ]; type WhatIsBool = typeof val[ 'bool' ];

Even though the intent of the API is to return a structure that's a subset of the plucked object, it has two unintended usability consequences with TypeScript's inference behavior:

The returned object has members are all of the type T | undefined . This will cause frustrations when using this pluck function in --strict mode. Keys that are not specified are optionally present in the returned object's type. We should be able to know that the bool key will never be present in the return type.

How can we verify compile-time inference behavior?

If we wanted API usability/behavior to act a certain way at runtime, we could write a few tests which assert that behavior and then modify our implementation of pluck so that our desired behavior is verified. However, since the behavior we want is something that is determined at compile-time, we need to resort to telling the compiler to perform these assertions for us at compile-time.

Using TypeScript 2.8's conditional types, we can define the shape and inference behavior of the API we want to build prior to actually implementing it. Think of this as a sort of TDD for your types.

We can do this by (1) asserting the inferred value is assignable to the types that we want (conditional types come in handy here), and (2) cause the compiler to reject code at compile time when these assertions are not true.

As a tiny example, if we want to write a compile-time test that asserts "this value should really be inferred as a number," we can do the following:

type AssertIsNumber<N> = N extends number ? true : never; let definitelyANumber = 3 ; let definitelyAString = "hello" ; let cond1: AssertIsNumber< typeof definitelyANumber> = true ;

Using these assertions to make a better pluck

Applying this technique to our API, we can describe the behavior we want for our case #1 (members having an unwanted | undefined ):

let obj1: MyStruct = { str: 'hello' , num: 123 , bool: true , }; let val1 = pluck([ 'str' , 'num' ], obj); let strIsString1: typeof val1[ 'str' ] extends string ? true : never = true ; let numIsNumber1: typeof val1[ 'num' ] extends number ? true : never = true ;

Excellent, now that we have a compile-time error that asserts our behavior, we can redefine pluck 's type signature to be more accurate.

function pluck2 < T, K extends keyof T >( keys: K[], obj: T ): Required < Partial< T > > { const accum: Partial<T> = {}; for ( let key of keys) { accum[key] = obj[key]; } return accum as Required< typeof accum>; } let obj2: MyStruct = { str: 'hello' , num: 123 , bool: true , }; let val2 = pluck2([ 'str' , 'num' ], obj2); let str2IsString: typeof val3[ 'str' ] extends string ? true : never = true ; let num2IsNumber: typeof val3[ 'num' ] extends number ? true : never = true ;

This compiles, which means our problem #1 is solved! Unfortunately, this signature is a lie. While we "fixed" #1, we still need to deal with our case #2, where missing members are still present in the returned type.

To check for this, we need a few type devices to fail compile if a key is present in a type:

Asserting the absence of a key

There are a few type operations that we need to know in order to check if an object does not have a key.

First off, here's a brief refresher on the building blocks we'll use:

type AndNever = true & never; type AndNeverMember = { str: string } & { str: never }; type StrMember = AndNeverMember[ 'str' ] type IndexToNever = { [key: string ]: never }; type StrPlusIndexFallback = IndexToNever & { str: string }; type StrWithFallback = StrPlusIndexFallback[ 'str' ]; type MissingWithFallback = StrPlusIndexFallback[ 'num' ];

So let's build a type device that evaluates to true when an object T does not have a key K :

type AllThingsTrue = { [key: string ]: true }; type Invert<T> = { [K in keyof T]: never }; type TrueIfMissing<T, K extends string > = (Invert<T> & AllThingsTrue)[K]; type ObjectWithMember = { present: string }; let missingKey: TrueIfMissing<ObjectWithMember, "missing" > = true ; let presentKey: TrueIfMissing<ObjectWithMember, "present" > = true ;

Putting it all together

Now with this TrueIfMissing type device, we can assert that we do not want to have certain keys present in the returned object from our pluck :

let bool2IsMissing: TrueIfMissing< typeof pluck2Result, 'bool' > = true ;

Finally we can create a version of pluck that satisfies all of our usability concerns:

function pluck3 < T, K extends keyof T >( keys: K[], obj: T ): {[Item in K]: T[Item]} { const accum: {[Item in K]?: T[Item]} = {}; for ( let key of keys) { accum[key] = obj[key]; } return accum as {[Item in K]: T[Item]}; } let obj3: MyStruct = { str: 'hello' , num: 123 , bool: true , }; let val3 = pluck3([ 'str' , 'num' ], obj3); let str3IsString: typeof val3[ 'str' ] extends string ? true : never = true ; let num3IsNumber: typeof val3[ 'num' ] extends number ? true : never = true ; let bool3IsMissing: TrueIfMissing< typeof val3, 'bool' > = true ;

Why go through all this work?

When we have automated tests which assert the behavior of our code, we gain confidence that changes to our software will not introduce regressions. However, when designing an API which is meant to leverage type inference to gain usability, there hasn't really been an obvious way of doing this.

This technique allows us to effectively test how TypeScript performs its inference for users of our API. We can build a test module which makes assertions about our desired type inference, and if the test file compiles successfully, our assertions are correct! That way, if our API subtly changes in a way that makes return values or callback parameters harder to infer, we can be alerted to this by a failure to compile.

If you happen to know of other techniques that can be used to accomplish this sort of compile-time assertion, I'd love to hear them! Please reach out and let me know!