Skip to content

# Bug Report: Omit-based assertion narrowing fails to override method return types on classes with private members #63250

@deyaaeldeen

Description

@deyaaeldeen

🔎 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

https://www.typescriptlang.org/play#code/JYOwLgpgTgZghgYwgAgEJwM4oN7JHAWwgC5kMwpQBzZAXwChRJZEUBRADzGQi4hAAmGNJhw8uUOKQBGAe1kAbCHBB1G4aPCRoIYOMlxEwAC1kCAFAEpSnMAG48EAO4B5AA5XSAN1nABa+gQFTGEAYQVgfm5semRkN0ovOEhkAH0OZABeZHgFLDtY5CNTC2sRLANkKF0AVyhVEBqFBQBCBwY4-jhpJVRdOE9kEOgwYRNgYQnkFwJgMAAecYwAGmQAImKzNYA+ZAAyHT0DBgZ6GBqQBDBgWVVIcisDQoRb8mQEUnDI8CzHJ2QvlErAU4ggAHRdHoQPp6YHPMEgZzuYFxVFouIAegx0wA0qREf9Nv4nLIoABrDDPV7cKC-cFEuFxKAQiRwBzojnILHINhQKCk0gAcl4FDgguQAlkEGEIFk3F4E24t2QYAAnm4UIL0FhBfRTtyAEoQAiyLwoNYJYBJFLpNZDQTIObIEnkjDEQLBDBhCJRFxkp5xBllbViapgOoNJqtdqFSG9fqDYZQUYq4xTKYzOaLNMrdZEnb7Q76bAnehnC5XG53aVgP2PGKg6nvT4+8B+34EgGt2tkxnvCH4KEwgaWEH9gnI0dUkBvWnZem6Ep95kiyTszlo7kuPFVaWKM1jWQ8rh6oA

💻 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 type Base (from the original class), not Ext (from Beta). ❌

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:

  1. Omit<Client, "method"> produces a mapped type that structurally removes method, but
  2. The Client (nominal) part of the intersection still carries method(): Base
  3. Beta adds method(): Ext
  4. Overload resolution sees both signatures and picks the first match (from the nominal Client part), returning Base

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:

Metadata

Metadata

Assignees

No one assigned

    Labels

    Not a DefectThis behavior is one of several equally-correct options

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions