Skip to content

Overloaded assertion in inferred-return function expression loses destructured discriminant narrowing #4572

Description

@adri1wald

Overloaded assertion in inferred-return function expression loses destructured discriminant narrowing for returned property

Steps to reproduce

Repository: https://github.com/adri1wald/typescript-v6-v7-narrowing-repro

Minimal failing code:

type State =
  | {
      status: 'ready'
      payload: {
        value?: string
      }
    }
  | {
      status: 'pending'
      payload?: undefined
    }

declare function getState(): State

declare function assert(
  condition: boolean,
): asserts condition

declare function assert(
  condition: boolean,
  message: string,
): asserts condition

const readValue = () => {
  const { status, payload } = getState()

  if (status !== 'ready') {
    throw new Error('Value is not ready.')
  }

  assert(payload.value !== undefined)

  return payload.value
}

Config:

{
  "compilerOptions": {
    "noEmit": true,
    "strict": true
  },
  "include": ["src/repro.ts"]
}

Commands:

pnpm install
pnpm run check:ts6
pnpm run check:ts7
pnpm run check:controls

The control cases in src/control-cases.ts all pass with both compilers. They narrow the discrepancy to this shape:

  • the state is narrowed through correlated destructuring: const { status, payload } = getState()
  • status !== 'ready' exits before the assertion
  • an overloaded assertion function checks payload.value
  • the enclosing function is a function expression or arrow function with no syntactic return type annotation
  • the function directly returns the destructured payload value or property

Nearby cases that pass with tsgo:

  • a single assertion signature
  • a single assertion signature with an optional message parameter
  • the same overloaded assertion in a function declaration
  • the same overloaded assertion in an arrow function with a syntactic return type annotation
  • the same overloaded assertion followed by an intermediate const value: string = payload.value
  • the same overloaded assertion using the original state object instead of correlated destructuring
  • an inline guard instead of an assertion function
  • the same overloaded assertion when the narrowed payload value is not returned

Behavior with typescript@6.0

No errors.

$ pnpm run check:ts6
$ tsc6 -p tsconfig.json --pretty false --noEmit

The control cases also pass:

$ pnpm run check:controls:ts6
$ tsc6 --ignoreConfig --strict --noEmit --pretty false src/control-cases.ts

Behavior with tsgo

tsgo reports payload as possibly undefined in the assertion argument:

$ pnpm run check:ts7
$ tsc -p tsconfig.json --pretty false --noEmit
src/repro.ts(31,10): error TS18048: 'payload' is possibly 'undefined'.

After this guard:

if (status !== 'ready') {
  throw new Error('Value is not ready.')
}

the remaining union member is the 'ready' member, where payload is defined.

The control cases pass with tsgo:

$ pnpm run check:controls:ts7
$ tsc --ignoreConfig --strict --noEmit --pretty false src/control-cases.ts

I also checked the current nightly:

$ pnpm --package=typescript@next dlx tsc --version
Version 7.1.0-dev.20260708.3

$ pnpm --package=typescript@next dlx tsc --ignoreConfig --strict --noEmit --pretty false src/repro.ts
src/repro.ts(31,10): error TS18048: 'payload' is possibly 'undefined'.

Metadata

Metadata

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions