Skip to content

Improve .origin preservation in getNarrowedTypeWorker #61589

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

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 75 additions & 27 deletions src/compiler/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27842,14 +27842,7 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
for (const t of types) {
const mapped = t.flags & TypeFlags.Union ? mapType(t, mapper, noReductions) : mapper(t);
changed ||= t !== mapped;
if (mapped) {
if (!mappedTypes) {
mappedTypes = [mapped];
}
else {
mappedTypes.push(mapped);
}
}
mappedTypes = append(mappedTypes, mapped);
}
return changed ? mappedTypes && getUnionType(mappedTypes, noReductions ? UnionReduction.None : UnionReduction.Literal) : type;
}
Expand Down Expand Up @@ -29343,33 +29336,88 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
// constituents that are unrelated to the candidate.
const isRelated = checkDerived ? isTypeDerivedFrom : isTypeSubtypeOf;
const keyPropertyName = type.flags & TypeFlags.Union ? getKeyPropertyName(type as UnionType) : undefined;
const narrowedType = mapType(candidate, c => {
// If a discriminant property is available, use that to reduce the type.
const discriminant = keyPropertyName && getTypeOfPropertyOfType(c, keyPropertyName);
const matching = discriminant && getConstituentTypeForKeyType(type as UnionType, discriminant);
// For each constituent t in the current type, if t and and c are directly related, pick the most
// specific of the two. When t and c are related in both directions, we prefer c for type predicates
// because that is the asserted type, but t for `instanceof` because generics aren't reflected in
// prototype object types.
const directlyRelated = mapType(
matching || type,
checkDerived ?
t => isTypeDerivedFrom(t, c) ? t : isTypeDerivedFrom(c, t) ? c : neverType :
t => isTypeStrictSubtypeOf(t, c) ? t : isTypeStrictSubtypeOf(c, t) ? c : isTypeSubtypeOf(t, c) ? t : isTypeSubtypeOf(c, t) ? c : neverType,
);
// If no constituents are directly related, create intersections for any generic constituents that
const matchedCandidates: Type[] = [];
let narrowedType = mapTypeInContextOfCandidate(
type,
type,
candidate,
(t, c) => {
// For each constituent t in the current type, if t and and c are directly related, pick the most
// specific of the two. When t and c are related in both directions, we prefer c for type predicates
// because that is the asserted type, but t for `instanceof` because generics aren't reflected in
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This whole change aims to avoid any major functional changes.

Conceptually, it just "flips" the order of the 2 nested for loops. Before candidates were iterated and that iteration was iterating through types. With this change, it's the other way around - types are iterated and they have an inner loop through candidates. This required some changes to pull this off (like the introduction of matchedCandidates).

But why this is even needed? The goal of this code is to narrow down a type by a candidate and to truly narrow down a type a mapType or filterType have to be used because they try to preserve .origin information. By having the outer loop mapping through candidate this "merely" produced a result related to type but not one that would directly be derived from it -so the .origin had very slim chances of surviving when narrowing by a union candidate

// prototype object types.
const directlyRelated = checkDerived ?
(isTypeDerivedFrom(t, c) ? t : isTypeDerivedFrom(c, t) ? c : neverType) :
(isTypeStrictSubtypeOf(t, c) ? t : isTypeStrictSubtypeOf(c, t) ? c : isTypeSubtypeOf(t, c) ? t : isTypeSubtypeOf(c, t) ? c : neverType);
if (!(directlyRelated.flags & TypeFlags.Never)) {
insertType(matchedCandidates, c);
}
return directlyRelated;
},
);
if (matchedCandidates.length !== countTypes(candidate)) {
// If there are leftover constituents not directly related, create intersections for any generic constituents that
// are related by constraint.
return directlyRelated.flags & TypeFlags.Never ?
mapType(type, t => maybeTypeOfKind(t, TypeFlags.Instantiable) && isRelated(c, getBaseConstraintOfType(t) || unknownType) ? getIntersectionType([t, c]) : neverType) :
directlyRelated;
});
narrowedType = getUnionType([
narrowedType,
mapType(candidate, c => {
return !containsType(matchedCandidates, c)
? mapType(type, t => maybeTypeOfKind(t, TypeFlags.Instantiable) && isRelated(c, getBaseConstraintOfType(t) || unknownType) ? getIntersectionType([t, c]) : neverType)
: neverType;
}),
]);
}
// If filtering produced a non-empty type, return that. Otherwise, pick the most specific of the two
// based on assignability, or as a last resort produce an intersection.
return !(narrowedType.flags & TypeFlags.Never) ? narrowedType :
isTypeSubtypeOf(candidate, type) ? candidate :
isTypeAssignableTo(type, candidate) ? type :
isTypeAssignableTo(candidate, type) ? candidate :
getIntersectionType([type, candidate]);

function mapTypeInContextOfCandidate(originalType: Type, type: Type, candidate: Type, mapper: (t: Type, c: Type) => Type): Type {
if (type.flags & TypeFlags.Never) {
return type;
}
if (!(type.flags & TypeFlags.Union)) {
return mapType(candidate, c => mapper(type, c));
}
const origin = (type as UnionType).origin;
const types = origin && origin.flags & TypeFlags.Union ? (origin as UnionType).types : (type as UnionType).types;
if (keyPropertyName) {
let matchedTypes: Type[] | undefined;
let matchingCandidates: Type[] | undefined;
const skipped = forEachType(candidate, c => {
const discriminant = keyPropertyName && getTypeOfPropertyOfType(c, keyPropertyName);
const matching = discriminant && getConstituentTypeForKeyType(originalType as UnionType, discriminant);
if (!matching || containsType(types, matching)) {
matchedTypes = append(matchedTypes, matching);
matchingCandidates = append(matchingCandidates, c);
return;
}
return true;
});
if (!skipped && matchedTypes) {
const mappedTypes: Type[] = [];
for (const t of matchedTypes) {
for (const c of matchingCandidates!) {
mappedTypes.push(mapper(t, c));
}
}
return getUnionType(mappedTypes);
}
}
let mappedTypes: Type[] = [];
let changed = false;
for (const t of types) {
const mapped = t.flags & TypeFlags.Union ? mapTypeInContextOfCandidate(originalType, t, candidate, mapper) : mapType(candidate, c => {
return mapper(t, c);
});
changed ||= t !== mapped;
mappedTypes = append(mappedTypes, mapped);
}
return changed ? getUnionType(mappedTypes) : type;
}
}

function narrowTypeByCallExpression(type: Type, callExpression: CallExpression, assumeTrue: boolean): Type {
Expand Down
8 changes: 4 additions & 4 deletions tests/baselines/reference/controlFlowOptionalChain.types
Original file line number Diff line number Diff line change
Expand Up @@ -2857,12 +2857,12 @@ function f30(o: Thing | undefined) {
> : ^^^^^^^^^^^^^^^^^^^^^^^^^^^

o.foo;
>o.foo : NonNullable<string | number | undefined>
> : ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
>o.foo : string | number
> : ^^^^^^^^^^^^^^^
>o : Thing
> : ^^^^^
>foo : NonNullable<string | number | undefined>
> : ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
>foo : string | number
> : ^^^^^^^^^^^^^^^
}
}

Expand Down
44 changes: 44 additions & 0 deletions tests/baselines/reference/narrowingUnionByUnionCandidate1.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//// [tests/cases/compiler/narrowingUnionByUnionCandidate1.ts] ////

//// [narrowingUnionByUnionCandidate1.ts]
// https://github.com/microsoft/TypeScript/issues/61581

type Result<A, E> =
| {
readonly _tag: "Ok";
readonly value: A;
}
| {
readonly _tag: "Fail";
readonly error: E;
};

declare const isResult: (u: unknown) => u is Result<any, any>;

// return type: Result<A, E> | "ok"
export const fn = <A, E>(inp: Result<A, E> | string) =>
isResult(inp) ? inp : "ok";


//// [narrowingUnionByUnionCandidate1.js]
"use strict";
// https://github.com/microsoft/TypeScript/issues/61581
Object.defineProperty(exports, "__esModule", { value: true });
exports.fn = void 0;
// return type: Result<A, E> | "ok"
var fn = function (inp) {
return isResult(inp) ? inp : "ok";
};
exports.fn = fn;


//// [narrowingUnionByUnionCandidate1.d.ts]
type Result<A, E> = {
readonly _tag: "Ok";
readonly value: A;
} | {
readonly _tag: "Fail";
readonly error: E;
};
export declare const fn: <A, E>(inp: Result<A, E> | string) => Result<A, E> | "ok";
export {};
49 changes: 49 additions & 0 deletions tests/baselines/reference/narrowingUnionByUnionCandidate1.symbols
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//// [tests/cases/compiler/narrowingUnionByUnionCandidate1.ts] ////

=== narrowingUnionByUnionCandidate1.ts ===
// https://github.com/microsoft/TypeScript/issues/61581

type Result<A, E> =
>Result : Symbol(Result, Decl(narrowingUnionByUnionCandidate1.ts, 0, 0))
>A : Symbol(A, Decl(narrowingUnionByUnionCandidate1.ts, 2, 12))
>E : Symbol(E, Decl(narrowingUnionByUnionCandidate1.ts, 2, 14))

| {
readonly _tag: "Ok";
>_tag : Symbol(_tag, Decl(narrowingUnionByUnionCandidate1.ts, 3, 5))

readonly value: A;
>value : Symbol(value, Decl(narrowingUnionByUnionCandidate1.ts, 4, 26))
>A : Symbol(A, Decl(narrowingUnionByUnionCandidate1.ts, 2, 12))
}
| {
readonly _tag: "Fail";
>_tag : Symbol(_tag, Decl(narrowingUnionByUnionCandidate1.ts, 7, 5))

readonly error: E;
>error : Symbol(error, Decl(narrowingUnionByUnionCandidate1.ts, 8, 28))
>E : Symbol(E, Decl(narrowingUnionByUnionCandidate1.ts, 2, 14))

};

declare const isResult: (u: unknown) => u is Result<any, any>;
>isResult : Symbol(isResult, Decl(narrowingUnionByUnionCandidate1.ts, 12, 13))
>u : Symbol(u, Decl(narrowingUnionByUnionCandidate1.ts, 12, 25))
>u : Symbol(u, Decl(narrowingUnionByUnionCandidate1.ts, 12, 25))
>Result : Symbol(Result, Decl(narrowingUnionByUnionCandidate1.ts, 0, 0))

// return type: Result<A, E> | "ok"
export const fn = <A, E>(inp: Result<A, E> | string) =>
>fn : Symbol(fn, Decl(narrowingUnionByUnionCandidate1.ts, 15, 12))
>A : Symbol(A, Decl(narrowingUnionByUnionCandidate1.ts, 15, 19))
>E : Symbol(E, Decl(narrowingUnionByUnionCandidate1.ts, 15, 21))
>inp : Symbol(inp, Decl(narrowingUnionByUnionCandidate1.ts, 15, 25))
>Result : Symbol(Result, Decl(narrowingUnionByUnionCandidate1.ts, 0, 0))
>A : Symbol(A, Decl(narrowingUnionByUnionCandidate1.ts, 15, 19))
>E : Symbol(E, Decl(narrowingUnionByUnionCandidate1.ts, 15, 21))

isResult(inp) ? inp : "ok";
>isResult : Symbol(isResult, Decl(narrowingUnionByUnionCandidate1.ts, 12, 13))
>inp : Symbol(inp, Decl(narrowingUnionByUnionCandidate1.ts, 15, 25))
>inp : Symbol(inp, Decl(narrowingUnionByUnionCandidate1.ts, 15, 25))

58 changes: 58 additions & 0 deletions tests/baselines/reference/narrowingUnionByUnionCandidate1.types
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//// [tests/cases/compiler/narrowingUnionByUnionCandidate1.ts] ////

=== narrowingUnionByUnionCandidate1.ts ===
// https://github.com/microsoft/TypeScript/issues/61581

type Result<A, E> =
>Result : Result<A, E>
> : ^^^^^^^^^^^^

| {
readonly _tag: "Ok";
>_tag : "Ok"
> : ^^^^

readonly value: A;
>value : A
> : ^
}
| {
readonly _tag: "Fail";
>_tag : "Fail"
> : ^^^^^^

readonly error: E;
>error : E
> : ^

};

declare const isResult: (u: unknown) => u is Result<any, any>;
>isResult : (u: unknown) => u is Result<any, any>
> : ^ ^^ ^^^^^
>u : unknown
> : ^^^^^^^

// return type: Result<A, E> | "ok"
export const fn = <A, E>(inp: Result<A, E> | string) =>
>fn : <A, E>(inp: Result<A, E> | string) => Result<A, E> | "ok"
> : ^ ^^ ^^ ^^ ^^^^^^^^^^^^^^^^^^^^^^^^
><A, E>(inp: Result<A, E> | string) => isResult(inp) ? inp : "ok" : <A, E>(inp: Result<A, E> | string) => Result<A, E> | "ok"
> : ^ ^^ ^^ ^^ ^^^^^^^^^^^^^^^^^^^^^^^^
>inp : string | Result<A, E>
> : ^^^^^^^^^^^^^^^^^^^^^

isResult(inp) ? inp : "ok";
>isResult(inp) ? inp : "ok" : Result<A, E> | "ok"
> : ^^^^^^^^^^^^^^^^^^^
>isResult(inp) : boolean
> : ^^^^^^^
>isResult : (u: unknown) => u is Result<any, any>
> : ^ ^^ ^^^^^
>inp : string | Result<A, E>
> : ^^^^^^^^^^^^^^^^^^^^^
>inp : Result<A, E>
> : ^^^^^^^^^^^^
>"ok" : "ok"
> : ^^^^

4 changes: 2 additions & 2 deletions tests/baselines/reference/narrowingUnionToUnion.types
Original file line number Diff line number Diff line change
Expand Up @@ -557,8 +557,8 @@ if (isEmpty(test)) {
> : ^^^^^^^^^^^^^^^^^^^^^^^^^

test; // EmptyString
>test : EmptyString
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Those changes are fine - it's the candidate that had .origin on it but the goal is to preserve type's .origin and not .candidate's. The contained types here are exactly the same

> : ^^^^^^^^^^^
>test : "" | null | undefined
> : ^^^^^^^^^^^^^^^^^^^^^
}

// Repro from #43825
Expand Down
Loading