Use typed identity functions to guide type inference

TypeScript tends to do a very good job of inferring types when you leave off explicit annotations. (Chapter 3 of Effective TypeScript is devoted to this topic.) But when you use tuples or string literal types, this will sometimes go wrong. This post explores using identity functions with carefully constructed type signatures to guide inference towards alternative types.

Say you have a function to calculate the distance between two points:

type Point = [number, number];
function dist([x1, y1]: Point, [x2, y2]: Point) {
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
}

If you define some points and try to call this function, you'll get an error:

const p1 = [0, 0];
const p2 = [3, 4];
let d = dist(p1, p2);
// ~~ Argument of type 'number[]' is not assignable
// to parameter of type 'Point'.
// Type 'number[]' is missing the following properties
// from type '[number, number]': 0, 1 (2345)

The issue is that TypeScript has inferred the types of p1 and p2 as number[], whereas you would have preferred Point. These aren't compatible (there are many number arrays that aren't Points) and hence the error.

There are a whole class of type errors like this that happen when the inferred type wasn't quite the one you had in mind. In this case the easiest solution is to either declare the type of each point:

const p1: Point = [0, 0];
const p2: Point = [3, 4];
let d = dist(p1, p2); // ok

or use a "const assertion" to avoid inferring the wider types:

const p1 = [0, 0] as const;
const p2 = [3, 4] as const;
let d = dist(p1, p2); // ok

In this case the inferred types are much narrower than Point, but they are assignable to Point, so this type checks.

But in this post I want to talk about a slightly different approach, which is to use identity functions that adjust the inferred type. Here's one way you could use an identity(ish) function to get a Point:

const Point = (x: number, y: number): Point => [x, y];

const p1 = Point(0, 0);
const p2 = Point(3, 4);
let d = dist(p1, p2); // ok

The Point function is a value, so it's fine that it has the same name as the Point type, which exists in a separate namespace (Item 8 in Effective TypeScript discusses how to know if a symbol is in type or value space). The ": Point" declares the return type of the function. This is preferable to as Point, which would not perform excess property checking.

This certainly solves the problem and is sometimes more convenient than using declarations. Examples of this pattern in the wild include Material-UI's createStyles and React Native's StyleSheet.create.

In the case of a tuple, though, there's a neat trick (which I learned from Boris Cherny's Programming TypeScript) to solve this more generally. You can use a generic identity function to get TypeScript to infer a tuple type, rather than an array:

const tuple = <T extends unknown[]>(...args: T): T => args;

const p1 = tuple(0, 0);
const p2 = tuple(3, 4);
let d = dist(p1, p2); // ok

If you mouse over p1 or p2 in the playground, you'll see that its type has been inferred as [number, number], just like we wanted.

This works with any sort of tuples, including those with mixed types:

const arr = [1, 'two', /three/]; // type is (string | number | RegExp)[]
const tup = tuple(1, 'two', /three/); // type is [number, string, RegExp]

The tuple function isn't one you'd write in plain JavaScript (it's shorter to use an array literal), but in the TypeScript context it becomes an extremely useful way to change up the inferred type.

You can apply the same idea if you want to let TypeScript infer the keys of an object but still provide an explicit type for the values. For example:

const capitals = {
ny: [-73.7562, 42.6526],
ca: [-121.4944, 38.5816],
ak: [-134.4197, 58.3019],
};

If you try to calculate the distance between two capitals, you'll get the same error as before:

dist(capitals.ny, capitals.ak);
// ~~~~~~~~~~~ Argument of type 'number[]' is not assignable
// to parameter of type 'Point'

(To calculate the actual distance you should use something like turf.distance.)

Mousing over capitals, you'll see its type is inferred as {ny: number[]; ca: number[]; ak: number[];}. You could wrap all the capital locations in tuple like before, but let's try writing a different identity function to force them all to be Points:

const withValueType = <V extends unknown>() =>
<T extends Record<PropertyKey, V>>(o: T) => o;

const capitals = withValueType<Point>()({
ny: [-73.7562, 42.6526],
ca: [-121.4944, 38.5816],
ak: [-134.4197, 58.3019],
});

let d = dist(capitals.ny, capitals.ak); // ok

If you mouse over the type of capitals now, it's

const capitals: {
ny: [number, number];
ca: [number, number];
ak: [number, number];
}

and the types all work out!

So how does withValueType work? In order to infer the object's type as something different, we first need to capture it in a generic argument, just like we did with tuple. This is T, and we want TypeScript to infer it. We also want to explicitly write the value type V (Point in this case). Unfortunately you can't have a function with one explicit generic parameter and one inferred parameter. TypeScript will either infer all the generic parameters to a function or none of them, not a mix.

There is a standard workaround for this problem: split the function into two, one with an explicit generic parameter and one with an inferred parameter. Instead of an identity function, we now have a function that returns an identity function. (Note the extra () after withValueType!)

It's important to note that this is quite different than using an index type or Record, which also fixes the type error:

// (Don't do this, see below!)
const capitalsRec: Record<string, Point> = {
ny: [-73.7562, 42.6526],
ca: [-121.4944, 38.5816],
ak: [-134.4197, 58.3019],
};

d = dist(capitalsRec.ny, capitalsRec.ak); // ok

The difference is that accessing an invalid key produces an error when you let TypeScript infer the keys, but not when you use an index type:

capitalsRec.in;  // allowed
capitals.in;
// ~~ Property 'in' does not exist on type ...

The inferred type of capitals is more like Record<'ny'|'ca'|'ak', Point> than Record<string, Point>. But because the keys were inferred, you didn't have to write 'ny'|'ca'|'ak' explicitly.

As a final example, you may find that you want to define an object that has some (but not all) of the fields of another object.

For example:

type CSSColor = 'aliceblue' | 'antiquewhite' | 'aqua' | 'black' // | ...;
interface DisplayValue {
value: number;
units: string;
color: CSSColor;
style: 'regular' | 'bold' | 'italic' | 'bolditalic';
}

const defaults = {
color: 'black',
style: 'regular',
};

const distanceToJupiter: DisplayValue = {
// ~~~~~~~~~~~~~~~~~
// Type ... is not assignable to type 'DisplayValue'.
// Types of property 'color' are incompatible.
// Type 'string' is not assignable to type 'CSSColor'.
...defaults,
value: 25_259_974_097_204,
units: 'inches',
};

What went wrong? Despite the error on distanceToJupiter, the problem is with defaults. Its type is inferred as { color: string; style: string; }, rather than the narrower value types required for a DisplayValue.

You might try to solve the problem by using Partial in the declaration of defaults:

const defaults: Partial<DisplayValue> = {
color: 'black',
style: 'regular',
};

const distanceToJupiter: DisplayValue = {
// ~~~~~~~~~~~~~~~~~
// ... Type 'undefined' is not assignable to type 'CSSColor'.
...defaults,
value: 25_259_974_097_204,
units: 'inches',
};

Now the issue is that you've lost the specificity of the keys in defaults. The Partial<DisplayValue> type marks all the properties as optional, including color and style which you've definitely specified. Hence the error about undefined. (You can see this error on the playground.)

You can't solve this using withValueType since the values for color and style have different types. Instead, you can craft a slightly different generic identity function. I call this withValueTypesFrom, since you're taking the value types from some other type (if you have a better name, please suggest it!):

const withValueTypesFrom = <V extends unknown>() =>
<K extends keyof V>(x: Pick<V, K>): Pick<V, K> => x;

const defaults = withValueTypesFrom<DisplayValue>()({
color: 'black',
style: 'regular',
}); // Type is Pick<DisplayValue, "color" | "style">

const distanceToJupiter: DisplayValue = {
...defaults,
value: 25_259_974_097_204,
units: 'inches',
}; // ok

It's worth repeating that none of the values have changed here, just the types. This has all the right properties: if you specify an invalid color in defaults, you'll get an error. If you misspell color as colour, you'll get an error. And if you leave out value or units in distanceToJupiter, you'll also get an error.

TypeScript tends to do a very good job of inferring types when you leave off explicit annotations. But when you use tuples or string literal types, this will sometimes go wrong. When this happens, type declarations or const assertions are typically the answer. But if you want to adjust inference in more general or complex ways, typed identity functions give you the flexibility to do so.

Do you have other examples of functions that help with inference? Chime in in the comments! You can find the full code from this example on the TypeScript playground. Some of the examples in this post come from an older post with a very different focus that I wrote for the LogRocket blog.

Like this post? Consider subscribing to my newsletter, the RSS feed, or following me on Twitter.
comments powered by Disqus
Effective TypeScript Book Cover

Effective TypeScript shows you not just how to use TypeScript but how to use it well. The book's 62 items help you build mental models of how TypeScript and its ecosystem work, make you aware of pitfalls and traps to avoid, and guide you toward using TypeScript’s many capabilities in the most effective ways possible. Regardless of your level of TypeScript experience, you can learn something from this book.

After reading Effective TypeScript, your relationship with the type system will be the most productive it's ever been! Learn more »