TypeScript compile-time inference assertions
Last updated
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);
// val infers to:
// let val: Partial<MyStruct>
type WhatIsStr = typeof val['str'];
// type WhatIsStr = string | undefined
type WhatIsNum = typeof val['num'];
// type WhatIsNum = number | undefined
type WhatIsBool = typeof val['bool'];
// type WhatIsBool = boolean | undefined
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 thispluck
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;
// 1. `Type1 extends Type2 ? Type3 : Type4` translates to:
// If a `Type1` type is assignable to `Type2`, then evaluate to `Type3`
// Otherwise, evaluate to the `Type4` type
let definitelyANumber = 3;
let definitelyAString = "hello";
let cond1: AssertIsNumber<typeof definitelyANumber> = true;
// let cond2: AssertIsNumber<typeof definitelyAString> = true;
// ^^^^^ - [ts] Type 'true' is not assignable to type 'never'.
// 2. The compiler knows `typeof definitelyAString` is a `string`
// and `string` is *not* assignable to `number`
// so `cond2` will be given the type `never`
// Therefore it will reject assigning `true` to a `never` type
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);
// val1 infers to:
// let val1: Partial<MyStruct>
let strIsString1: typeof val1['str'] extends string ? true : never = true;
// ^^^^^^^^^^^^ - [ts] Type 'true' is not assignable to type 'never'.
let numIsNumber1: typeof val1['num'] extends number ? true : never = true;
// ^^^^^^^^^^^^ - [ts] Type 'true' is not assignable to type 'never'.
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);
// val2 infers to:
// let val2: Required<Partial<MyStruct>>
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 AndNever = never
// --> anything `& never` will *always* evaluate to `never`
type AndNeverMember = { str: string } & { str: never };
type StrMember = AndNeverMember['str']
// type StrMember = never
// --> because `string & never` is `never`
type IndexToNever = { [key: string]: never };
type StrPlusIndexFallback = IndexToNever & { str: string };
type StrWithFallback = StrPlusIndexFallback['str'];
// type StrWithFallback = string
// --> When looking up objects with index signatures,
// the compiler *first* checks the defined members
// and then falls back to the index signature.
type MissingWithFallback = StrPlusIndexFallback['num'];
// type MissingWithFallback = never
// --> Since 'num' is not defined, the 'never' index is used
So let's build a type device that evaluates to true
when an object T
does not have a key K
:
// - Take an object which has a fallback as `true`:
type AllThingsTrue = { [key: string]: true };
// - And one which is `never` for the defined keys in `T`:
type Invert<T> = { [K in keyof T]: never };
// And merge them with a key lookup:
type TrueIfMissing<T, K extends string> = (Invert<T> & AllThingsTrue)[K];
// Demonstrating simply:
type ObjectWithMember = { present: string };
let missingKey: TrueIfMissing<ObjectWithMember, "missing"> = true;
let presentKey: TrueIfMissing<ObjectWithMember, "present"> = true;
// ^^^^^^^^^^ - [ts] Type 'true' is not assignable to type 'never'.
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;
// ^^^^^^^^^^^^^^ - [ts] Type 'true' is not assignable to type 'never'.
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);
// val3 now infers to:
// let val3: {
// str: string;
// num: number;
// }
// Which means all of the following assertions correctly compile:
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!