HomeArticles

TypeScript: Unexpected intersections

Stefan Baumgartner

Stefan on Mastodon

More on TypeScript, JavaScript

Sometimes when writing TypeScript, some of the things you’d usually do in JavaScript work a little different and cause some weird, and puzzling situations. Sometimes you just want to assign a value to an object property and get a weird error like “Type ‘string | number’ is not assignable to type ‘never’. Type ‘string’ is not assignable to type ‘never’.(2322)”

Don’t worry, this isn’t something out of the ordinary, it’s just something where “unexpected intersection types” make you think a little bit more about the type system.

Index access types and assignments #

Let’s look at this example:

let person = {
name: "Stefan",
age: 39
}

type Person = typeof person;

let anotherPerson: Person = {
name: "Not Stefan",
age: 20
};

function update(key: keyof Person) {
person[key] = anotherPerson[key]; // 💥
}

update("age");

We create a little function that lets us update things from one object anotherPerson to object person via providing a key. Both person and anotherPerson have the same type Person, but TypeScript throws error 2322 at us: Type ‘string | number’ is not assignable to type ‘never’. Type ‘string’ is not assignable to type ‘never’..

So what’s the deal?

Property assignments via the index access operator are super hard to track down for TypeScript. Even if you narrow down all possible access keys via keyof Person, the possible values that can be assigned are string or number (for name and age respectively). While this is ok if you have an index access on the right-hand side of a statement (reading), it gets a little interesting if you have an index access on the left-hand side of a statement (writing).

TypeScript can’t guarantee, that the value you pass along is actually correct. Look at this function signature:

function update_ambiguous(key: keyof Person, value: Person[keyof Person]) {
//...
}

update_ambiguous("age", "Stefan");

Nothing prevents me from adding a falsely typed value to every key. Except for TypeScript, which throws an error at us. But why does TypeScript tell us the type is never?

To allow for some assignments TypeScript compromises. Instead of not allowing any assignments at all on the right-hand side, TypeScript looks for the lowest common denominator of possible values. Take this for example:

type Switch = {
address: number,
on: 0 | 1
}

declare const switcher: Switch;
declare const key: keyof Switch;

Here, both keys are subsets of number. Well, address is the entire set of numbers, on on the other side is either 0 or 1. It’s absolutely possible to set 0 or 1 to both fields! And this is what you get with TypeScript as well.

switcher[key] = 1; //👍
switcher[key] = 2; //💥 Nope!

TypeScript gets to the possible assignable values by doing an intersection type of all property types. This means that in the case of the Switch, it’s number & (0 | 1), which boils down to 0 | 1. In the case of all Person properties, it’s string & number, which has no overlap, therefore it’s never! Hah! There’s the culprit!

So what can you do about it?

One way to get around this strictness (which is for your own good!) is by using generics. Instead of allowing all keyof Person values to access, we bind a specific subset of keyof Person to a generic variable:

function update<K extends keyof Person>(key: K) {
person[key] = anotherPerson[key]; // 👍
}

update("age");

When I do update("age"), K is bound to the literal type of "age". No ambiguity there!

There is a theoretical loophole since we could instantiate update with a much broader generic value:

update<"age" | "name">("age")

But this is something the TypeScript team allows… for now. See also this comment by Anders Hejlsberg. Note that Anders asks to see use cases for such a scenario, which perfectly details how the TypeScript team works. The original assignment via index access on the right-hand side has so much potential for errors, that they give you enough safeguards until you make it very intentional what you want to do. This is ruling out entire classes of errors without getting too much in the way.

Ambiguous functions #

There is another scenario where you experience unexpected intersection types. Take this wonderful discriminated union type for example:

type Singular = {
value: string,
validate: (val: string) => boolean,
kind: "singular"
}

type Multiple = {
value: string[],
validate: (val: string[]) => boolean,
kind: "multiple"
}

type Props = Singular | Multiple

Classy. Some very similar types with a nice literal type to create a distinction. But when we start using this in a function, things suddenly break:

function validate({ validate, value, kind }: Props) {
if (kind === "singular") {
validate(value); // 💥 Oh no!
}
}

The error that TypeScript throws at us is similar to the previous error, we get Error 2345: Argument of type ‘string | string[]’ is not assignable to parameter of type ‘string & string[]’.

Okay, so where does the intersection type string & string[] come from? The problem lies in the destructuring of our input arguments. The moment we destructure validate, value and kind out of our Props, they lose connection to the original type. Suddenly, we have three different types to deal with:

  • kind of type "singular" | "multiple"
  • value of type string | string[]
  • validate of type (val: string) => boolean | (val: string[]) => boolean

Again, no connection to the original type Props. So the moment we check for "singular", we are not hopping into another branch of the type system. This means that at the time we call validate, TypeScript thinks that it can be either one of both function types. It tries to create the lowest common denominator of all possible function types by creating an intersection type of all arguments of all functions.

So for the function to type-safely work, you would have to pass in a value of type string & string[]. Which is again very rare, actually impossible to have, some would say this can never happen.

So what can you do?

The answer is quite simple: Don’t destructure. In this case, it’s much easier to keep the original type relation intact.

function validate(props: Props) {
if(props.kind === "singular") {
props.validate(props.value);
}
}

TypeScript now knows exactly where to branch, and what types your object’s properties get.

The shocking final: A combination! #

It can get even harder 😱

Let’s look at the following structure:

type FormFields = {
age: {
value: number,
validator: (val: number) => boolean
},
name: {
value: string,
validator: (val: string) => boolean
}
}

You might already know where I’m getting at. What if I want to access a certain property via index access (a key), and then call the function with the associated value. Let’s try it with all the things we have learned so far:

function validate<K extends keyof FormFields>(key: K, forms: FormFields) {
forms[key].validator(forms[key].value) // 💥 TS2345
}

Nope, no can-do! Even though we bound the key to a specific value and we didn’t destructure our arguments, we have no possibility to run this. The problem is that both index accesses are reading operations. Which means that TypeScript just creates a union type for each property:

  • forms[key].validator is of type (val: number) => boolean | (val: string) => boolean
  • forms[key].value is of type number | string

Which means that TypeScript tries to call all possible values of number | string to an intersected function type: (val: number & string) => boolean. number & string is again never, in case you wondered.

And this is something that’s really hard to overcome. Because the moment we do an index access to forms, all we get is are union types. To make this work, we would need forms[key].validator to be (val: number | string ) => boolean. And that requires a bit of a journey.

First of all, let’s create a generic type that represents our fields. This comes in handy later.

type Field<T> = {
value: T,
validator: (val: T) => T
}

type FormFields = {
age: Field<number>,
name: Field<string>
}

With that Field<T> type, we can create a validate function that does what it should do:

function validate_field<T>(obj: Field<T>) {
return obj.validator(obj.value);
}

So far, so good. With that, we can already do validations of the like

validate_field(forms.age);

We still have a little problem once we do an index access:

function validate<K extends keyof FormFields>(key: K, forms: FormFields) {
let obj = forms[key];
validate_field(obj); // 💥 TS2345
}

Same problem. But, since we know better, we can help TypeScript’s type system with a little push into the right direction:

function validate<K extends keyof FormFields>(key: K, forms: FormFields) {
let obj = forms[key];
validate_field(obj as Field<typeof obj.value>);
}

Phew. While we usually don’t want to have type assertions, this one is totally valid. We point TypeScript to a specific branch in our union type and narrow it down to a clear subset. With typeof obj.value and the way Field is structured, there is no ambiguity and we know, that this is correct. The rest is done by the wonderfully type-safe function interfaces!

As an alternative, we could do an explicit type annotation of obj, where I allow a much broader type that encompasses all possible values:

function validate<K extends keyof FormFields>(key: K, forms: FormFields) {
let obj: Field<any> = forms[key];
validate_field(obj);
}

Whatever you like. Do you have more ideas? Please let me know!

Bottom line #

TypeScript has the unique and extraordinary task of attaching a type system to an incredibly flexible language. And TypeScript tries to be as sound as possible in doing so. This means that for some tasks, it gets very strict and rules out cases and statements where there’s no immediately visible problem. And whenever we encounter a situation like that, there are ways to discuss with the type system on what’s correct and what isn’t. That’s the uniqueness, and the power of a gradual type system.

If you want to read more, I highly recommend this issue that details the reasoning for improving the soundness of index access types. There’s also a couple of playgrounds for you

Big shoutout to Ty and Pezi for giving me some brain teasers. This was fun and I hope you gained as many insights as I did!

More articles on TypeScript

Stay up to date!

3-4 updates per month, no tracking, spam-free, hand-crafted. Our newsletter gives you links, updates on oida.dev, conference talks, coding soundtracks, and much more.