question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Incorrect narrowing for nested tag unions

See original GitHub issue

Bug Report

🔎 Search Terms

“nested union narrowing”, “nested tag union”

🕗 Version & Regression Information

This is the behavior in every version I tried, and I reviewed the FAQ.

⏯ Code

type Config =
  | { type: 'text'; value: string }
  | { type: 'number'; value: number }
  | { type: 'window' | 'section'; children: Config[] }
  ;

function foo(config: Config) {
  if (config.type === 'window' || config.type === 'section') {
    return '';
  }
  return config.value; // <-- Property 'value' does not exist on type '{ type: "window" | "section"; children: Config[]; }'.
}

type Config2 =
  | { type: 'text'; value: string }
  | { type: 'number'; value: number }
  | { type: 'window'; children: Config[] }
  | { type: 'section'; children: Config[] }
  ;

function foo2(config: Config2) {
  if (config.type === 'window' || config.type === 'section') {
    return '';
  }
  return config.value; // <-- OK
}

Output
"use strict";
function foo(config) {
    if (config.type === 'window' || config.type === 'section') {
        return '';
    }
    return config.value;
}
function foo2(config) {
    if (config.type === 'window' || config.type === 'section') {
        return '';
    }
    return config.value;
}

Compiler Options
{
  "compilerOptions": {
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictPropertyInitialization": true,
    "strictBindCallApply": true,
    "noImplicitThis": true,
    "noImplicitReturns": true,
    "alwaysStrict": true,
    "esModuleInterop": true,
    "declaration": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "target": "ES2017",
    "jsx": "react",
    "module": "ESNext",
    "moduleResolution": "node"
  }
}

Playground Link: Provided

🙁 Actual behavior

Getting an error in foo: Property 'value' does not exist on type '{ type: "window" | "section"; children: Config[]; }'.

This is strange because changing config.value to config.type on that last line of foo and hovering over it shows that it was correctly narrowed to "number" | "text". However, the config.value access shows the error above that suggests that config was narrowed to { type: "window" | "section"; children: Config[]; } instead, even though it’s the branch that was supposed to be discarded by if.

🙂 Expected behavior

foo to compile successfully, just like foo2.

Issue Analytics

  • State:open
  • Created 2 years ago
  • Comments:5 (4 by maintainers)

github_iconTop GitHub Comments

2reactions
fatcerberuscommented, Jul 14, 2021

I still don’t understand why the error says “Property ‘value’ does not exist on type ‘{ type: “window” | “section”; children: Config[]; }’.” though - the worst case scenario, it should say “on type Config”, no?

It actually does say “on type Config”–the subsequent message is simply elaboration telling you which constituent of Config caused the property access to fail and has nothing to do with the attempted narrowing. The fact that it matches the type you were trying to narrow away inside the condition is just a coincidence.

Whenever you see multiple lines for a type error, you can mentally prepend “because” to every line after the first.

1reaction
RyanCavanaughcommented, Jul 14, 2021

The problem here is that narrowing proceeds step-by-step, and after determining config.type === 'window' to be false, we can’t narrow the union at all – its original three constituents are still around. If there was a single operation to exclude window and section as an atomic step, then the code would work as expected:

declare function isWindowOrSection(s: Config): s is Config & { type: "window" | "section"};

function foo(config: Config) {
  if (isWindowOrSection(config)) {
    return '';
  }
  return config.value;
}

We’d need negated types to form a new type, Config & { type: not 'window' } at the || point in order to make this work.

Read more comments on GitHub >

github_iconTop Results From Across the Web

Type narrowing of "one level deep" discriminated union in ...
I believe that there is an issue in TS , because it is unable to infer nested properties of unions. Here is the...
Read more >
narrowing types via type guards and assertion functions - 2ality
The problem in this case is that, without narrowing, we can't access property .second of a value whose type is FirstOrSecond . The...
Read more >
Documentation - Narrowing - TypeScript
The in operator narrowing​​ JavaScript has an operator for determining if an object has a property with a name: the in operator. TypeScript...
Read more >
C++ Core Guidelines - GitHub Pages
tag is the anchor name of the item where the Enforcement rule appears ... unions; casts; array decay; range errors; narrowing conversions ......
Read more >
Modeling with Union Types - Thoughtbot
Tagged unions are a killer language feature as they allow you to expressively model problem domains and avoid some of the pitfalls of ......
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found