Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Repeated in checks of asserted/predicate-d type don't work #61526

Open
nathanielyoon opened this issue Apr 3, 2025 · 7 comments
Open

Repeated in checks of asserted/predicate-d type don't work #61526

nathanielyoon opened this issue Apr 3, 2025 · 7 comments
Labels
Design Limitation Constraints of the existing architecture prevent this from being fixed

Comments

@nathanielyoon
Copy link

πŸ”Ž Search Terms

in, union, assertion, predicate, interface

πŸ•— Version & Regression Information

This is the behavior in every version I tried, and I reviewed the FAQ for entries about common misconceptions regarding {} and properties

⏯ Playground Link

https://www.typescriptlang.org/play/?ts=5.9.0-dev.20250403#code/JYOwLgpgTgZghgYwgAgEJwM4oN4ChnIYAWcUEAJgPoAOUA9tdGAJ4BchYUoA5gNy4BfXKEixEKALJ0MYADbMAogFtqLZBAAekEOQxpMOISOjwkyAPIgUm7bv1ZkeAnSvsZXEH0HDwJ8cgAVAHc6dS0IHT10BydkMBC3Th5+I18xMwCiMmtwyPscfDisiAhEjy8hFkZkAGU6JRQAXmQpGXllVWZkAB8LKx7AkIHM7P4YAFcQBDBgF2RMLCgwSgx6iAAKVYb2SYBrEDogkABKdgWmPS2UYD06hschCamZuZuVtc21nZB9w5O3NbIG61QGxMhgcZQEBxKDjCApXAIFwyQhfZB7A5HZDNbACfjCGDIdZvK6fBrHY6OQoAempyDou0KACIXBAmUDoVdkAAybmohoAOlZAtkEW4YCI-AITPidHZoH5KF5ioFspFYolUuQMpC8s5gOVV1VIXVnk1uBpdOg9CghWAhPWLKsesVlKNwtFZsldodTrZHNdKo9GslyFpyAA5O6rBGgXo6ISqigIxi-hGBT6iTq5QGrm61sa6KbxaHw1GC7LY8CE3FmNUUz9MSB095cOclu8GmSIMd+OGGbg-S6uYaC8Gvfxs8ODXyjWrPSXJ7Lp-dR4L5yG+1aoDaCVnWSue0GrMXzfb987c2t84Lx4uw3Ty7eY3H6Ym68nU0cW+fHcur+SKobhOD6RnOIRVvG771l+zYZkAA

πŸ’» Code

interface Base {
  shared_property: string;
}
interface MostlyEmpty extends Base {}
interface One extends Base {
  one: string;
}
interface Two extends Base {
  two: string;
}
interface Three extends Base {
  three: string;
}
type Some = MostlyEmpty | One | Two | Three;
function assert_some(some: unknown): asserts some is Some {}
function is_some(some: unknown): some is Some {
  return true;
}
const some: unknown = {};

if (is_some(some)) {
  // ok
  "one" in some && some.one.length;
  "two" in some && some.two.length;
  "two" in some && some.two.length;

  // error
  if ("one" in some) some.one.length;
  if ("one" in some) some.one.length; // 'some.one' is of type 'unknown'.
  if ("two" in some) some.two.length; // 'some.two' is of type 'unknown'.
}

assert_some(some);
// ok
"one" in some && some.one.length;
"two" in some && some.two.length;
"two" in some && some.two.length;
// error
if ("one" in some) some.one.length;
if ("one" in some) some.one.length; // 'some.one' is of type 'unknown'.
if ("two" in some) some.two.length; // 'some.two' is of type 'unknown'.

πŸ™ Actual behavior

Types don't get inferred past the first branch.

πŸ™‚ Expected behavior

All branches should infer the type.

Additional information about the issue

I'm implementing JSON Type Definition types, and I'm running into some problems
with type narrowing. A JTD schema can be empty (except for common metadata and
nullable properties) or it can take a form based on its keys. When using a
type predicate to check something fits a general schema type, weird things
happen when i use the in operator to check for the presence of keys. If it has
a "type" property, it's the type form, if it has a "values" property, it's the
values form, so on. I'm checking for the presence of "properties" or
"optionalProperties" which works fine for the first check, but subsequent
unrelated branches assume that the type is impossible, and it's gotta be one of
the other union members. This only happens with if, not with conditional
expressions. here's the original code:

interface Shared {
  metadata?: { [key: string]: unknown };
  nullable?: boolean;
}
interface Empty extends Shared {}
interface Ref extends Shared {
  ref: string;
}
interface BooleanType extends Shared {
  type: "boolean";
}
interface NumericType extends Shared {
  type: `float${32 | 64}` | `${"" | "u"}int${8 | 16 | 32}`;
}
interface StringType extends Shared {
  type: "string";
}
interface TimestampType extends Shared {
  type: "timestamp";
}
interface Enum extends Shared {
  enum: readonly [string, ...string[]];
}
interface Elements extends Shared {
  elements: Schema;
}
interface WithProperties extends Shared {
  properties: { [key: string]: Schema };
  optionalProperties?: { [key: string]: Schema };
  additionalProperties?: boolean;
}
interface WithOptional extends Shared {
  properties?: { [key: string]: Schema };
  optionalProperties: { [key: string]: Schema };
  additionalProperties?: boolean;
}
export type Properties = WithProperties | WithOptional;
interface Values extends Shared {
  values: Schema;
}
interface Discriminator extends Shared {
  discriminator: string;
  mapping: { [tag: string]: Properties };
}
export type Schema =
  | Empty
  | Ref
  | BooleanType
  | NumericType
  | StringType
  | TimestampType
  | Enum
  | Elements
  | Properties
  | Values
  | Discriminator;

const schema: unknown = {};
function assert_schema(schema: unknown): asserts schema is Schema {}
function is_schema(schema: unknown): schema is Schema {
  return true;
}

if (is_schema(schema)) {
  // ok
  "properties" in schema && schema.properties?.[""].nullable;
  "properties" in schema && schema.properties?.[""].nullable;
  "optionalProperties" in schema && schema.properties?.[""].nullable;

  // error
  if ("properties" in schema) schema.properties?.[""].nullable;
  if ("properties" in schema) schema.properties?.[""].nullable;
  if ("optionalProperties" in schema) schema.properties?.[""].nullable;
}
assert_schema(schema);
// ok
"properties" in schema && schema.properties?.[""].nullable;
"properties" in schema && schema.properties?.[""].nullable;
"optionalProperties" in schema && schema.properties?.[""].nullable;

// error
if ("properties" in schema) schema.properties?.[""].nullable;
if ("properties" in schema) schema.properties?.[""].nullable;
if ("optionalProperties" in schema) schema.properties?.[""].nullable;

I made it work by using Record<string, none> & Shared for the empty form, but
that doesn't feel right (and type inference is messed up in a different but
non-erroring way).

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Apr 3, 2025

AI explanation of this, which is correct:


The key point is that although you start with the union type Some (which is written as Base | One | Two | Three), every member of that union is actually a subtype of Base. When you use the type guard with the "one" in some check, TypeScript narrows the type differently in each branch:

In the if ("one" in some) branch:
TypeScript refines some to those types in the union that have a property named "one". This narrows it to the type One.

In the else branch:
Here, TypeScript eliminates the possibility of One (since if "one" were present, it would have gone into the if branch). What remains is the union of the types that do not have "one", which is Base | Two | Three.

After the if/else block:
Now, TypeScript must assign a type to some that is valid for both branches. It computes the union of One (from the if branch) and (Base | Two | Three) (from the else branch). However, because every one of these types extends Base, their union is effectively simplified to just Base.

In other words, once you combine the branches, the extra properties that distinguish One, Two, and Three are β€œlost” because the only common structure across both branches is what is defined in Base. That's why the final type of some is inferred as Base.

This behavior highlights how TypeScript’s control flow analysis and union simplification work when the union members share a common base type.


There's not an obvious way to fix this without breaking other invariants, but folks are welcome to try

@RyanCavanaugh RyanCavanaugh added the Design Limitation Constraints of the existing architecture prevent this from being fixed label Apr 3, 2025
@nathanielyoon
Copy link
Author

I didn't emphasize this as much as I could've, but there are no else (or else if) statements. Each of the branches is independent, so the checks should have no effect on each other. They aren't mutating the objects or anything that would change the type, so why can't they run sequentially like this?

@RyanCavanaugh
Copy link
Member

There's still an implicit join point in the control flow graph, since the body of the if could contain an early exit. Treating if (e) { } differently than if (e) { } else { } just makes things more confusing rather than less.

@nathanielyoon
Copy link
Author

This only seems to happen when the type comes from an assert/is function. If it's from declare or as or something it works fine. Why is that any different?

@nathanielyoon
Copy link
Author

To clarify:

source of type conditional ok?
is or asserts predicate if statement no
as assertion or : declaration if statement yes
is or asserts predicate conditional && expression yes
as assertion or : declaration conditional && expression yes

Shouldn't they all be the same, or if it has to do with control-flow branching, shouldn't the first two and last two be the same?

@RyanCavanaugh
Copy link
Member

Different code doing different things is not per se unexpected. in has special rules since it's technically unsound.

@nathanielyoon
Copy link
Author

The different behavior isn't contingent on whether you use in or not. It seems like something's different about getting the type from one of those functions as opposed to an annotation on the variable. Is that intended behavior? If so, why?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Design Limitation Constraints of the existing architecture prevent this from being fixed
Projects
None yet
Development

No branches or pull requests

2 participants