typescriptintersection-types

Why does TypeScript not simplify the intersection of a type and one of its super-types?


Is there a way to get TypeScript's checker to simplify out unnecessary elements of intersection types, or am I wrong that they are unnecessary?

IIUC, the type SubType & SuperType is equivalent to SubType, but typescript does not seem to perform that simplification.

As seen below, I define a class Sub as a declared sub-type of both class Base and interface I.

I then define generic functions for each super-type that takes a <T> and if it's a passes a guard for that super-type, returns T & SuperType.

I'd expect that T & SuperType would simplify to T because every T is a SuperType but the type checker does not do that simplification.

Link to code in TS playground

interface I {
    readonly isI: true
}

class Base {
    // HACK: Having a private member forces nominal typing
    private readonly isBase: true = true;
    toString() {
        // Use the private field to quiet warning.
        return `isBase=${this.isBase}`;
    }
}

class Sub extends Base implements I {
    readonly isI: true = true
}

// Custom type guards.
function isI(x: unknown): x is I {
    return typeof x === 'object' && x !== null && 'isI' in x &&
        (x as ({['isI']: unknown}))['isI'] === true;
}

function isBase(x: unknown): x is Base {
    return x instanceof Base;
}

// Intersect with an inferred type parameters.
function andI<T>(x: T): T & I {
    if (isI(x)) {
        return x;
    } else {
        throw new Error();
    }
}

function andBase<T>(x: T): T & Base {
    if (isBase(x)) {
        return x;
    } else {
        throw new Error();
    }
}

let sub = new Sub();
let subAndI = andI(sub); // Has type (Sub & I)
let subAndBase = andBase(sub); // Has type (Sub & Base)

// Sub and (Sub&I) are mutually assignable
let sub2: Sub = subAndI;
subAndI = sub2;

The TS playground compiles this without errors nor warnings. That let sub2: Sub = subAndI seems to pass suggests to me that But the type inferences (in the ".D.TS" tab) include:

declare let sub: Sub;
declare let subAndI: Sub & I;
declare let subAndBase: Sub & Base;

not, as I expected

declare let sub: Sub;
declare let subAndI: Sub;
declare let subAndBase: Sub;

Sub&I seems mutually assignable with Sub but is there some risk in treating the two as equivalent types in TS?


Solution

  • The closest documented discussion I can find to this topic is in microsoft/TypeScript#16386, and issue I filed in 2017 asking for TypeScript to implement the so-called absorption laws whereby if T extends U, then T | U would consistently reduce to U and T & U would consistently reduce to T.

    My issue was eventually closed as kind of "fixed" and "declined" at the same time. Some, but not all, of this behavior eventually got implemented. It was never explicitly stated in that issue exactly why intersection reduction was not fully implemented, so what follows is my (hopefully educated) speculation.


    TL;DR: Such across-the-board simplification would either break things people rely on, or not be worth it in terms of TS dev team time or compiler performance.

    If TypeScript's type system were fully sound, and if type simplification were the main consideration for designing TypeScript, then these laws probably should be implemented. But, though type system soundness is one of the guiding principles for TS development, it isn't the only one.

    TypeScript's type system isn't fully sound, and it isn't intended to be (see TypeScript Design Non-Goal #3, as well as the discussion in microsoft/TypeScript#9825). Therefore some "obviously true" laws about types can't be implemented without breaking other things.

    For example, in a sound type system, subtyping is transitive, and thus T extends U and U extends V would imply that T extends V. But this is not always true in TypeScript. Optional properties behave in unsound but useful ways, so {a?: string} extends {} and {} extends {a?: number}, but not {a?: string} extends {a?: number}. In a sound type system, intersections are commutative, and thus T & U should be the same type as U & T. Alas, this is again, not always true in TypeScript. Overloaded functions with multiple call signatures are order-dependent, and thus {(): string} & {(): number} is a different type from {(): number} & {(): string}. Any changes that are made to this would have to be carefully considered in order to not break lots of real world code.

    And simplification for simplification's sake is also not a goal in TypeScript. For example, excess property checking warns people when they initialize objects that have properties the compiler will completely forget about, since it's often indicative of a programming mistake. So const x: {a: string} = {a: "", b: 0} produces a warning because that b property will not be tracked. and probably a mistake. On the other hand, const x: {a: string} | {a: string, b: number} = {a: "", b: 0} does not produce a warning, because the compiler will expect that a b property might be present. But {a: string} | {a: string, b: number} would be reduced to {a: string} if absorption laws were rigorously applied everywhere. Such simplification is not considered more important than excess property checking.

    If type simplification rules were automatically applied, you'd have observable and breaking changes in the type checker behavior. But even if it broke nothing, it still might not be implemented. Presumably the compiler would have to take processing time to apply the proposed reduction rule. Maybe such time would be paid back by downstream performance savings, but it's not obviously true. Someone would need to spend time and effort implementing and testing it. And adding more rules makes the compiler behavior more complex. It takes a lot for even non-breaking changes to be considered worthwhile.


    Anyway, if you really care about simplifying intersections, you could consider doing so manually via helper function. Anywhere you currently write X & Y you could write MyIntersect<X, Y> defined as

    type MyIntersect<T, U> =
        [T] extends [U] ? T :
        [U] extends [T] ? U :
        T & U;
    

    This has the desired behavior in your example code:

    function andI<T>(x: T): MyIntersect<T, I> { /* ... */ }
    function andBase<T>(x: T): MyIntersect<T, Base> { /* ... */ }
    
    let sub = new Sub();
    let subAndI = andI(sub); // Has type Sub
    let subAndBase = andBase(sub); // Has type Sub
    

    Playground link to code