-
Notifications
You must be signed in to change notification settings - Fork 13.3k
Description
🔎 Search Terms
asserts this is, Omit, private members, overload resolution, intersection, method return type, nominal typing, assertion narrowing
🕗 Version & Regression Information
- Tested on TypeScript 5.9.3, 5.4.5, and nightly
- This is the behavior in every version I tried
⏯ Playground Link
💻 Code
interface Base { name: string }
interface Ext extends Base { extra: boolean }
interface Beta { method(): Ext; newOp(): void }
class Client {
private _x = false;
method(): Base { return null!; }
enableBeta(): asserts this is Omit<this, "method"> & Beta {}
}
function test() {
const c: Client = new Client();
c.enableBeta();
c.newOp(); // OK: new method works
const r = c.method();
r.extra; // Error: 'extra' does not exist on type 'Base'
}
// Remove "private _x" and it works:
class ClientOk {
method(): Base { return null!; }
enableBeta(): asserts this is Omit<this, "method"> & Beta {}
}
function testOk() {
const c: ClientOk = new ClientOk();
c.enableBeta();
c.newOp();
const r = c.method();
r.extra; // OK: resolves to Ext
}🙁 Actual behavior
After c.enableBeta(), the narrowed type of c is Client & Omit<Client, "method"> & Beta.
- New methods from
Beta(e.g.newOp()) are accessible. ✅ - Calling
c.method()resolves to return typeBase(from the original class), notExt(fromBeta). ❌
The Omit<this, "method"> was intended to remove method from the class type so that the intersection with Beta provides the only method signature. Instead, overload resolution still picks the original class method.
🙂 Expected behavior
After assertion narrowing with asserts this is Omit<this, "method"> & Beta, calling c.method() should return Ext, since Omit was used to explicitly remove the original method signature.
Key observation: removing private fixes it
If the private _x field is removed, everything works correctly:
class ClientOk {
// no private members
method(): Base { return null!; }
enableBeta(): asserts this is Omit<this, "method"> & Beta {}
}
function testOk() {
const c: ClientOk = new ClientOk();
c.enableBeta();
const r = c.method();
r.extra; // ✅ works — resolves to Ext
}Additional information about the issue
With private members, TypeScript treats the class nominally. In the narrowed intersection type Client & Omit<Client, "method"> & Beta:
Omit<Client, "method">produces a mapped type that structurally removesmethod, but- The
Client(nominal) part of the intersection still carriesmethod(): Base Betaaddsmethod(): Ext- Overload resolution sees both signatures and picks the first match (from the nominal
Clientpart), returningBase
Without private members, the class is structural, and Omit effectively removes method from the type, allowing Beta.method() to be the only match.
Additionally, at the type level, conditional type extraction does correctly resolve the return type:
type ExtractReturn<T> = T extends { method(): infer R } ? R : never;
type R = ExtractReturn<typeof c>; // correctly resolves to Ext!So the type system internally knows the correct answer, but call-site overload resolution disagrees.
Related issues:
- TS treats methods within type intersection as overloads instead of narrowing when parameters match #53777 — Intersection methods treated as overloads ("first match wins") — closed as Working as Intended
- Return types of intersection of functions are incomplete and depend on order of declaration - an algorithm to fix it. #57095 — Return types of intersection functions are order-dependent — open suggestion
- Assertion Functions cannot assert over private properties - unnecessary/wrong error message? #47778 — Assertion functions cannot assert over private properties — open