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

Let primitive types discriminate a union of objects #10

Open
wants to merge 28 commits into
base: main
Choose a base branch
from

Conversation

jfet97
Copy link
Owner

@jfet97 jfet97 commented Dec 7, 2024

Main goal

The ultimate goal of this PR is to allow primitive types in object unions to act as discriminants, even when there are no unit/literal types involved. Some examples of this are as follows:

interface State<Type> {
  state: Type;
}

interface UserName {
  first: string;
  last?: string;
}

const nameState = {} as {
  value: string;
  state: State<string>;
} | {
  value: UserName;
  state: State<UserName>;
}

// here 'value' is considered a discriminant property
if (typeof nameState.value === "string") {
  nameState.state satisfies State<string>;
} else {
  nameState.state satisfies State<UserName>;
}
declare const arr: [string, number] | [number, string];

// here 'arr[0]' is considered a discriminant property
if (typeof arr[0] === "string") {
  arr[1] satisfies number;
} else {
  arr[1] satisfies string;
}
type ToRet<T extends { a: string } | { a: number }> =
  T extends { a: string }
    ? string
    : T extends { a: number }
      ? number
      : never

// here by narrowing on 'a' we can type this function, thanks the new capabilities from PR #56941
function aStringOrANumber<T extends { a: string } | { a: number }>(param: T): ToRet<T> {
  if (typeof param.a === "string") {
    return param.a.repeat(3);
  }
  if (typeof param.a === "number") {
    return Math.exp(param.a);
  }
  throw new Error()
}

If a property was not previously considered a discriminant, it will now be treated as one when the following conditions are met:

  1. The property must be non-uniform; specifically, at least two constituents of the union must have it with distinct types.
  2. The property must include at least one primitive type as a possible type.

 

Implementation details

isDiscriminantProperty were a cached boolean in the links of transient symbols, so the first attempt consisted of just adding inside the isDiscriminantProperty function (prop.links.checkFlags & CheckFlags.HasNonUniformType) && someType(propType, t => !!(t.flags & TypeFlags.Primitive) as a fallback.

Unfortunately, I found a couple of problems with this.

The first one is related with tests/cases/conformance/types/union/contextualTypeWithUnionTypeObjectLiteral.ts:

var objStrOrNum3: { prop: string } | { prop: number } = {
    prop: strOrNumber
};

The above assignment must fail (you can find the reason in that conformance test), but considering prop as a discriminant field changed the behaviour of TS, ultimately allowing the assignment. I ended up disabling the new discriminativeness of prop in similar situations and the simpler solution was by using a flag, which I had to take into consideration when it comes to caching the final result.
I have to say this whole thing isn't particularly clean, but that test case is 10 years old, that behaviour is literally set in stone, and I don't have the guts to change it, even though the change would be more permissive so it shouldn't be a breaking change, hopefully.

The second problem is related to 60702. Enabling the discriminativness on a particular prop makes objects that declared that property as never disappear when narrowing. TLDR TS stopped compiling itself here because of how ArrowFunction is defined: its name property has never type so it disappeared as a possible type of node after the assertion. Therefore I changed its type from : never to ?: never as suggested By Ryan, both in src/compiler/types, where the problematic ArrowFunction lies, and in tests/baselines/reference/api/typescript.d.ts.

I don’t think this last change is problematic; rather, I think a possible indirect breaking change might lie in the fact that if a property shared between objects is typed as never in one or more cases and is now considered discriminant due to this additional discriminating capability of TS, then the type containing it could end up being eliminated by TS in various circumstances. I’d like to point out that this already happens when a property is considered discriminant, as can be seen in the linked issue. Simply put, enhancing TS’s capabilities in this regard increases the cases where it can occur.

 

Final considerations

I really appreciate the simplicity of the additional fallback proposed in the PR, although the need to add a flag makes it a bit less clean. Perhaps a more experienced eye could remove it entirely, or realize that the fallback actually needs to be disabled in other call points of isDiscriminantProperty, even though the baseline tests didn’t highlight any other issues.

I’m really afraid of creating inconsistencies by not disabling this new behaviour wherever it is needed.
One example is discriminateContextualTypeByObjectMembers, which indirectly calls isDiscriminantProperty by discriminateContextualTypeByObjectMembers. It is stated that findMatchingDiscriminantType must be kept up-to-date with discriminateContextualTypeByObjectMembers, and findMatchingDiscriminantType calls findDiscriminantProperties which is the one that disables the new behaviour. Should I disable it inside discriminateContextualTypeByObjectMembers too?

Lastly, I wonder if isDiscriminantWithNeverType should take into account what’s been added by this PR. I’m quite unsure about it.

 

Future work

From tests/cases/compiler/narrowingDestructuring.ts:

function farr<T extends [number, string, string] | [string, number, number]>(x: T) {
    const [head, ...tail] = x;
    if (typeof x[0] === 'number') {
        const [head, ...tail] = x;
    }
}

Even if x has type [number, string, string] when typeof x[0] is 'number', tail keeps having type [string, string] | [number, number] instead of just [string, string].

@@ -4989,7 +4989,7 @@ declare namespace ts {
readonly modifiers?: NodeArray<Modifier>;
readonly equalsGreaterThanToken: EqualsGreaterThanToken;
readonly body: ConciseBody;
readonly name: never;
readonly name?: never;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is basically name?: undefined without exactOptionalPropertyTypes (and TS codebase doesnt use this flag). Is this property truly optional at runtime anyway? or is it always set to undefined?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems that createArrowFunction do not set it at all

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idk if it is set to undefined somewhere else, I'd say it isn't. Even updateArrowFunction ignores it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants