Hackle's blog
between the abstractions we want and the abstractions we get.

TypeScript Fireworks: Union, Intersection and Variance

Why is { 'f1': string, 'f2': number } & { 'f2': number, 'f3': string } not { 'f2': number }?

And ready for some fireworks with the wild mix of union, intersection and variance?

(It must be the street-light effect that I keep going back to co-variants and contra-variants across a few languages. Most recently it's for the magic of conversion from union to tuple type.)

Union and Intersection: count values, not fields

Give two types A and B, when put in a union A | B, a looser type is created. If we count its values, the union type have values from A + values from B, so union types are also called sum types. This feature itself is straightforward - albeit sorely missing from many main stream languages.

When an intersection is created from A & B, a stricter / more specific type is created. It only has values that are both A and B. This can be confusing to many, especially when it comes to object types.

Quite reminiscent of algebra, we can start by looking at the intersection of unions.

type T1 = 'a' | 'b';
type T2 = 'b' | 'c';

// type T3 = "b"
type T3 = T1 & T2;

Very intuitive and true to the name "intersection". But how about this?

type T4 = { 'f1': string, 'f2': number };
type T5 = { 'f2': number, 'f3': string };

type T6 = T4 & T5;

Surely T6 should be { 'f2': number }?! This was exactly what I first thought. However T6 is actually { 'f1': string, 'f2': number, 'f3': string }. Why is that?

The reason is the & operator does not really inspect the fields of the object types, instead, the intersection applies to the values of T4 and T5. What does that mean?

Think of T4 and T5 not as objects, because they are not - they are types! If it makes any sense, turn them into interfaces.

interface I4 { 'f1': string, 'f2': number };
interface I5 { 'f2': number, 'f3': string };

interface I6 extends I4, I5 {}

A trained Object-oriented mind (that's you, yes) should grok this right away. Any value of interface I6 must have all 3 fields to satisfy both I4 and I5 - it's a stricter type than each.

The name "intersection" makes sense if we count the values of each type: values of I6 is the intersection of values of I4 and I5.

Union and intersection of one

The philosophical and curious will find there is an interesting case to the union and intersection duality. When standing alone, a type is both a single-case union and a single-case intersection on its own. This may sound like a smart-ass revelation, but it can offer some interesting perspectives.

type T7 = { 'f1': string };
type T8 = { 'f2': number };

type T9 = T7 | T8;
const v9_1: T9 = { 'f1': 'a' };
const v9_2: T9 = { 'f2': 1 };

type T10 = T7 & T8;
const v10: T10 = { 'f1': 'a', 'f2': 1 };

By using | or & as combinator, we can build up more complex types. For example, this naive yet fun Parse type.

type Parse<T extends string> = 
    T extends `${infer T1} or ${infer T2}`
    ? Parse<T1> | Parse<T2>
    : T extends `${infer T1} and ${infer T2}`
        ? Parse<T1> & Parse<T2>
        : { [k in T]: true };

const p1: Parse<'a and b and c'> = { 'a': true, 'b': true, 'c': true };
const p2_a: Parse<'a or b'> = { 'a': true };
const p2_b: Parse<'a or b'> = { 'b': true };

Intersection of keys of union

Intersection and union can appear quite closely in unexpected places. An easy example is when we try to access the properties of a union type - the available keys are the intersection of the keys of all the cases of the union type.

// type T4 = { 'f1': string, 'f2': number };
// type T5 = { 'f2': number, 'f3': string };

type T11 = T4 | T5;

// const v11: "f2"
declare const v11: keyof T11;

// const v12: "f2", same!
declare const v12: keyof T4 & keyof T5;

This makes sense: only the common keys can be safe for operations such as T11['f2']; but not T11['f1'] as it's unsafe for T5.

Variance recap

We already know that TypeScript respects variance (previously discussed in C#). Never missing the opportunity to look at it again.

With covariance, typically when T is the return type of a function.

type Covariant<T> = () => T;

let co1: Covariant<'a' | 'b'>;

declare const co2: Covariant<'a'>;
co1 = co2; // this is ok

declare const co3: Covariant<'a' | 'b' | 'c'>;
/*
Type 'Covariant<"a" | "b" | "c">' is not assignable to type 'Covariant<"a" | "b">'.
  Type '"a" | "b" | "c"' is not assignable to type '"a" | "b"'.
    Type '"c"' is not assignable to type '"a" | "b"'.ts(2322)
*/
co1 = co3;

However, when T is a parameter type, then the assignability is reversed - quite the mind-bender!

type ContraVariant<T> = (arg: T) => void;

let contr1: ContraVariant<'a' | 'b'>;

declare const contr2: ContraVariant<'a' | 'b' | 'c'>;
contr1 = contr2;    // this is ok!

declare const contr3: ContraVariant<'a'>;
/*
Type 'ContraVariant<"a">' is not assignable to type 'ContraVariant<"a" | "b">'.
  Type '"a" | "b"' is not assignable to type '"a"'.
    Type '"b"' is not assignable to type '"a"'.ts(2322)
*/
contr1 = contr3;

We can also give them signs: co-variance is positive and contra-variance negative. Take some time, let that sink in.

Variance, union and intersection, the fantastic mix

Intersection or union alone is not fun enough; things become really interesting when variance is involved.

Before we jump in, we need a couple of utility types from a previous post, Contra<T> that takes T to a contra-variant position, namely, as a parameter. Note T can be any type, even a Contra<T> itself. And Co<T> for the opposite.

type Co<T> = () => T;
type Contra<T> = (arg: T) => void;

Then we have InferContra and InferCo that recovers T, with an important note: if Fn is a union type, it will be matched only once, as [Fn] stops union distribution. So it's not the complete reverse engineering of Co or Contra. That is possible without the [Fn] trick.

type InferCo<Fn> = 
    [Fn] extends [Co<infer T>] ? T : never;

type InferContra<Fn> = 
    [Fn] extends [Contra<infer T>] ? T : never;

Now let's see what happens.

/*
const infer_co1: {
    f1: 'f1';
} | {
    f2: 'f2';
} 
*/
declare const infer_co1: InferCo<
    Co<{ f1: 'f1' }> |
    Co<{ f2: 'f2' }>
>;

/*
const contra1: {
    f1: 'f1';
} & {
    f2: 'f2';
}
*/
declare const contra1: InferContra<
    Contra<{ f1: 'f1' }> |
    Contra<{ f2: 'f2' }>
>;

// remember ^ is equivalent to
declare const infer_contra2: [
    ((arg: { f1: 'f1' }) => void) |
    ((arg: { f2: 'f2' }) => void)
] extends [ ((arg: infer I) => void) ] 
    ? I 
    : never;

Do you see what's happening? InferCo is plain predictable, but InferContra from a union of two contra-variant types returns an intersection type! (Ok that's quite a mouthful.) Contra-variance strikes again in stunning fashion.

Inferred twice?

Can we make sense of it? Well... kind of. We can "desugar" InferContra further for this specific case,

type InferUnionContra<Fn> =
    [Fn] extends [((arg: infer T) => void) | ((arg: infer T) => void)]
    ? T
    : never;
    
/*
const infer_union_contra: {
    f1: 'f1';
} & {
    f2: 'f2';
}
*/
declare const infer_union_contra: InferUnionContra<
    Contra<{ f1: 'f1' }> |
    Contra<{ f2: 'f2' }>
>;

Notice how infer T can be used twice in the same extends clause? That forces TypeScript to return a single type that accounts for both of its appearances - depending on its positioning (therefore variance).

But how do we know for sure that InferContra is giving us the correct result?

Equational reasoning, and variance again

Let's call on a pretty reputable judge of character, Equational Reasoning (multiple rounds of thunder and lightning)! This is done by expanding the conditional type by putting in the actual input and output.

/* 
    remember: 
    type Contra<T> = (arg: T) => void;
    type InferContra<Fn> = [Fn] extends [Contra<infer T>] ? T : never;
*/

// the expanded form of: Contra<{ f1: 'f1' }> | Contra<{ f2: 'f2' }>
type UnionInput = ((arg: { f1: 'f1' }) => void) | ((arg: { f2: 'f2' }) => void);

type IntersectionSatisfiesEquation = 
    [UnionInput] extends [((arg: { f1: 'f1' } & { f2: 'f2' }) => void)] ? true : false;

Let's also recap: T extend U holds if T is a subtype of U, such as 'a' extends string. In another word, A value of T can be assigned to a variable of U.

By this reasoning InferContra is proof that the union ((arg: { f1: 'f1' }) => void) | ((arg: { f2: 'f2' }) => void) is a subtype of ((arg: { f1: 'f1' } & { f2: 'f2' }) => void). This can be proven as below.

declare let contra_union: ((arg: { f1: 'f1' }) => void) | ((arg: { f2: 'f2' }) => void);
declare let contra_intersection: ((arg: { f1: 'f1' } & { f2: 'f2' }) => void);

// a subtype can be assigned to a supertype
contra_intersection = contra_union;

Which, by distribution, means each case of the union type ((arg: { f1: 'f1' }) => void) | ((arg: { f2: 'f2' }) => void) can be assigned to the intersection ((arg: { f1: 'f1' } & { f2: 'f2' }) => void). By variance manipulation, this further means { f1: 'f1' } & { f2: 'f2' } is a subtype (mind the shift of direction) of either { f1: 'f1' } or { f2: 'f2' }, which is true, as we have discussed in the beginning. Check!

Or, { f1: 'f1' } is a super type of { f1: 'f1' } & { f2: 'f2' }, and ((arg: { f1: 'f1' } & { f2: 'f2' }) => void) is a super type of (arg: { f1: 'f1' }) => void).

Contra of contra of contra-variant

We are not done yet. Knowing how contra-variance can be thought of as the minus sign, and double minus equals plus (genius!), we can test out the thoroughness of this behaviour (or feature). It's showtime.

type InferContra2<T> = 
    [T] extends [Contra<Contra<infer I>>] ? I : never;

type InferContra3<T> = 
    [T] extends [Contra<Contra<Contra<infer I>>>] ? I : never;

type InferContra4<T> = 
    [T] extends [Contra<Contra<Contra<Contra<infer I>>>>] ? I : never;
    
/*
const contra2: {
    f1: 'f1';
} | {
    f2: 'f2';
}
*/
declare const contra2: InferContra2<
    Contra<Contra<{ f1: 'f1' }>> |
    Contra<Contra<{ f2: 'f2' }>>
>;

/*
const contra3: {
    f1: 'f1';
} & {
    f2: 'f2';
}
*/
declare const contra3: InferContra3<
    Contra<Contra<Contra<{ f1: 'f1' }>>> |
    Contra<Contra<Contra<{ f2: 'f2' }>>>
>;

/*
const contra4: {
    f1: 'f1';
} | {
    f2: 'f2';
}
*/
declare const contra4: InferContra4<
    Contra<Contra<Contra<Contra<{ f1: 'f1' }>>>> |
    Contra<Contra<Contra<Contra<{ f2: 'f2' }>>>>
>;

I'll be damned... It is CHECK, CHECK and CHECK. The result types alternate between union and intersection. TypeScript really thought this through. I am impressed.

Link to the source code used above.