2

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?

Mike Samuel
  • 118,113
  • 30
  • 216
  • 245
  • typescript won't change your type declarations behind your back. `export type X = {a: any, b: any} & {b: any}` doesn't get simplified to `{a: any, b: any}` either. The actual type checking happens dynamically during build time or in the editor. – MHebes May 26 '22 at 17:56
  • @MHebes, it does simplify redundant members out of union types though including all mentions of *never*. If it's going to simplify redundant lower-bounds out of union, why not simplify redundant upper-bounds out of intersection? Recognizing that *never* is redundant is probably cheaper than an upper bound below top, but it seems this failure to simplify will cascade. – Mike Samuel May 26 '22 at 18:45
  • My history with things like this (e.g., [ms/TS#16386](https://github.com/microsoft/TypeScript/issues/16386)) has taught me that TS does not aggressively simplify everything it can. First, there are some behaviors in the language that are observably different every time such things are implemented (e.g., `A & B` is different from `B & A` if they have multiple call signatures; excess property warnings on `A` differ from those on `A | (A & B)`; etc.) so they can't just do it without much testing. Second, such simplifications take processing time so they'd need to show it's worth it. – jcalz May 26 '22 at 19:34
  • I don't know how to answer your question authoritatively, though. "Why doesn't this happen" isn't something someone can answer with any authority unless there is some document written by people who make those decisions. I can't find a specific GitHub issue about reducing intersections the way you describe, so I can't answer with any more confidence than my previous comment. Does that suffice here? Or are you looking for something else? – jcalz May 26 '22 at 19:38
  • @jcalz, Thanks for explaining. I'm doing some code generation and using the compiler API. Code generators can stress language tools in ways that human-maintained code doesn't. If I keep accreting big types like `AbstractFoo | (FooImpl1 & AbstractFoo) | (FooImpl2 & AbstractFoo)` that could but don't reduce to just `AbstractFoo`, does the checker hit a wall at some point? – Mike Samuel May 26 '22 at 21:53
  • @jcalz, thanks for the pointer to that thread. I see the discussion about internal ( https://github.com/microsoft/TypeScript/issues/16386#issuecomment-380291389 ) and for-display ( https://github.com/microsoft/TypeScript/issues/16386#issuecomment-383186255 ) representations. – Mike Samuel May 26 '22 at 22:00
  • I can write up an answer with my non-authoritative opinions on why TS does not simplify the intersections in your question; would that work for you? Are you asking a followup question in the comments about the checker having some performance issue? I'm not sure how best to answer that, but it seems out of scope here. I'd say that if I really cared about such things I might use some helper type function to collapse intersections for me, like [this](https://tsplay.dev/N9E9qN). Does that help in any way? Is it in or out of scope for this question? – jcalz May 27 '22 at 02:41
  • That'd be great @jcalz. I can checkmark your writeup when you've got it ready. Thanks for the pointer to your collapsing code. – Mike Samuel May 27 '22 at 15:40

1 Answers1

1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360