From 50efe3f09d8c72cd81ad222accfc45b4a99abe8b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 13 Oct 2026 14:32:59 +0800 Subject: [PATCH 1/2] feat(check): prototype uniform primitive union narrowing Experimentally port microsoft/typescript-go#4781 (c5ed97d875) with mutable-copy and non-uniform union regression coverage. This does not change the declared TypeScript 7.0.2 baseline or qualify a complete baseline upgrade. --- src/sema/check/flow.rs | 21 +++++++ src/sema/check/unions.rs | 116 +++++++++++++++++++++++++++-------- test/cli/check/check.test.ts | 90 +++++++++++++++++++++++++++ 3 files changed, 201 insertions(+), 26 deletions(-) diff --git a/src/sema/check/flow.rs b/src/sema/check/flow.rs index 97d8a95aa1e5..f478136f9ca3 100644 --- a/src/sema/check/flow.rs +++ b/src/sema/check/flow.rs @@ -3430,6 +3430,9 @@ impl<'p, 's> Checker<'p, 's> { return TypeId::OBJECT; } } + if !loose && let Some(regular) = self.regular_uniform_union_member(ty, value_ty) { + return regular; + } // `isCoercibleUnderDoubleEquals` let coerces = loose && (matches!(value_ty, TypeId::NUMBER | TypeId::STRING) @@ -3444,6 +3447,12 @@ impl<'p, 's> Checker<'p, 's> { return self.replace_primitives_with_literals(kept, as_written); } if self.is_unit(value_ty) { + if self.is_uniform_union(ty) { + let filtered = self.remove_type(ty, value_ty); + if filtered != ty { + return filtered; + } + } let is_plain = Self::is_literal_outside_enum(self.flags(value_ty)); return self.filter(ty, |c, m| { if is_plain && Self::is_literal_outside_enum(c.flags(m)) { @@ -3455,6 +3464,16 @@ impl<'p, 's> Checker<'p, 's> { ty } + fn regular_uniform_union_member(&mut self, ty: TypeId, value: TypeId) -> Option { + if self.has_primitive_flag(value) && self.is_uniform_union(ty) { + let regular = self.regular(value); + if self.union_contains_type(ty, regular, false) { + return Some(regular); + } + } + None + } + /// FOR SPEED: a string, number or bigint literal type that is not a member of an enum. No rule /// of `isSimpleTypeRelatedTo` relates two of them, so they are comparable only if their regular /// types are the same. `x.kind !== "a"` tests every member of the type of `x.kind`. @@ -4470,6 +4489,8 @@ impl<'p, 's> Checker<'p, 's> { let discriminant = self.union(clause_types); let case_type = if discriminant.is_never() { TypeId::NEVER + } else if let Some(regular) = self.regular_uniform_union_member(ty, discriminant) { + regular } else { let kept = self.filter(ty, |c, m| c.are_comparable(discriminant, m)); self.replace_primitives_with_literals(kept, discriminant) diff --git a/src/sema/check/unions.rs b/src/sema/check/unions.rs index 34987773cde5..2d9c7b9d73e8 100644 --- a/src/sema/check/unions.rs +++ b/src/sema/check/unions.rs @@ -1575,6 +1575,39 @@ impl<'p, 's> Checker<'p, 's> { }) } + /// `isUniformUnionType`: no two literal constituents can compare equal. + pub(super) fn is_uniform_union(&self, ty: TypeId) -> bool { + if !self.is_primitive_union(ty) { + return false; + } + let mut enum_symbol = None; + let mut has_literal = false; + for &t in self.parts(ty) { + let flags = self.flags(t); + if flags & tf::ENUM_LIKE != 0 { + if has_literal { + return false; + } + let parent = match *self.data(t) { + TypeData::EnumLit { member, .. } => self.files().parent_of_symbol(member), + TypeData::Enum { symbol, .. } => self.files().parent_of_symbol(symbol), + _ => None, + }; + if enum_symbol.is_none() { + enum_symbol = parent; + } else if enum_symbol != parent { + return false; + } + } else if flags & (tf::STRING_LITERAL | tf::NUMBER_LITERAL) != 0 { + if enum_symbol.is_some() { + return false; + } + has_literal = true; + } + } + true + } + /// `intersectUnionsOfPrimitiveTypes`: multiple unions of primitives, which is what `keyof (A | /// B | C)` produces, are intersected as sets. The common members replace the first union, and /// the other unions are removed. `false`: there are fewer than two. @@ -1619,34 +1652,65 @@ impl<'p, 's> Checker<'p, 's> { /// `eachUnionContains`: `"a"` is in a union that has `string`. fn each_union_contains(&self, unions: &[TypeId], t: TypeId) -> bool { - for &union in unions { - let has = |expected: TypeId| self.contains_type(self.parts(union), expected); - if has(t) { - continue; - } - if t == TypeId::MISSING { - return has(TypeId::UNDEFINED); - } - if t == TypeId::UNDEFINED { - return has(TypeId::MISSING); - } - let flags = self.flags(t); - let primitive = if flags & tf::STRING_LITERAL != 0 { - TypeId::STRING - } else if flags & (tf::ENUM | tf::NUMBER_LITERAL) != 0 { - TypeId::NUMBER - } else if flags & tf::BIGINT_LITERAL != 0 { - TypeId::BIGINT - } else if flags & tf::UNIQUE_ES_SYMBOL != 0 { - TypeId::SYMBOL - } else { - return false; - }; - if !has(primitive) { - return false; + unions + .iter() + .all(|&union| self.union_contains_type(union, t, true)) + } + + /// `unionContainsType`. Equality narrowing must not match a unique symbol through `symbol`. + pub(super) fn union_contains_type(&self, union: TypeId, t: TypeId, match_symbol: bool) -> bool { + let has = |expected: TypeId| self.contains_type(self.parts(union), expected); + if has(t) { + return true; + } + if t == TypeId::MISSING { + return has(TypeId::UNDEFINED); + } + if t == TypeId::UNDEFINED { + return has(TypeId::MISSING); + } + let flags = self.flags(t); + let primitive = if flags & tf::STRING_LITERAL != 0 { + TypeId::STRING + } else if flags & (tf::ENUM | tf::NUMBER_LITERAL) != 0 { + TypeId::NUMBER + } else if flags & tf::BIGINT_LITERAL != 0 { + TypeId::BIGINT + } else if flags & tf::UNIQUE_ES_SYMBOL != 0 && match_symbol { + TypeId::SYMBOL + } else { + return false; + }; + has(primitive) + } + + /// `removeType`: unlike `filterType`, an unnamed remainder discards the union's origin. + pub(super) fn remove_type(&mut self, ty: TypeId, target: TypeId) -> TypeId { + if let UnionOrigin::Union(origin) = self.origin(ty) + && self.contains_type(origin, target) + { + return self.filter(ty, |_, t| t != target); + } + let parts = self.parts(ty); + let Some(at) = parts.iter().position(|&t| t == target) else { + return ty; + }; + let mut kept = Flat::from_slice(parts); + kept.remove(at); + match kept[..] { + [] => TypeId::NEVER, + [only] => only, + _ => { + let first_new_type_id = self.types().first_new_type_id(); + let result = self.types().intern_key(TypeKey::Union(&kept)); + if self.types().is_new_since(result, first_new_type_id) + && !self.is_primitive_union(ty) + { + self.types().mark_without_primitive_union(result); + } + result } } - true } pub(super) fn is_unknown_empty_object(&self, ty: TypeId) -> bool { diff --git a/test/cli/check/check.test.ts b/test/cli/check/check.test.ts index 42d33a60a7d2..5445ff7aa6e2 100644 --- a/test/cli/check/check.test.ts +++ b/test/cli/check/check.test.ts @@ -2806,6 +2806,96 @@ const kind: number = Shape.kind; `); }); + test("uniform union literals keep narrowing in mutable locations", async () => { + using dir = project({ + "a.ts": ` +function backup(opts: { status?: string }) { + if (opts.status !== "ok" && opts.status !== "failed") throw new Error(); + const result = { ...opts, status: opts.status }; + const accepted: { status: "ok" | "failed" } = result; + // @ts-expect-error: narrowing does not permit another status. + result.status = "other"; +} +function strictEquality(value: string | undefined) { + if (value === "ok") { + const result = { value }; + const accepted: { value: "ok" } = result; + // @ts-expect-error: the regular literal must remain narrow. + result.value = "other"; + } +} +function numberEquality(value: string | number) { + if (value === 1) { + const accepted: { value: 1 } = { value }; + } +} +function bigintEquality(value: bigint | undefined) { + if (value === 1n) { + const accepted: { value: 1n } = { value }; + } +} +function switchEquality(value: string | undefined) { + switch (value) { + case "ok": { + const accepted: { value: "ok" } = { value }; + break; + } + } +} +function looseEquality(value: string | undefined) { + if (value == "ok") { + const result = { value }; + // @ts-expect-error: loose equality retains literal freshness. + const rejected: { value: "ok" } = result; + } +} +function nonUnion(value: string) { + if (value === "ok") { + const result = { value }; + // @ts-expect-error: a non-union still retains literal freshness. + const rejected: { value: "ok" } = result; + } +} +enum First { A = "a", B = "b" } +enum Second { A = "a", B = "b" } +function sameEnum(value: First | undefined) { + if (value === First.A) { + const accepted: { value: First.A } = { value }; + } +} +function mixedLiteral(value: First.A | "a" | undefined) { + if (value === First.A) { + // @ts-expect-error: the string literal is also comparable. + const rejected: First.A = value; + } +} +function mixedEnums(value: First.A | Second.A | undefined) { + if (value === First.A) { + const accepted: First.A = value; + } +} +declare const key: unique symbol; +function symbolEquality(value: symbol | undefined) { + if (value === key) { + // @ts-expect-error: symbol is not a set of unique-symbol literals. + const rejected: typeof key = value; + } +} +export {}; + +namespace N1 { export enum E { A = 1, B = 2 } } +namespace N2 { export enum E { A = 1, B = 2 } } +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +function distinctEnums(value: N1.E | N2.E) { + if (value === N1.E.A) { + const accepted: Equal = true; + } +} +`, + }); + expect(await check(dir)).toEqual({ stdout: "", stderr: "", exitCode: 0 }); + }); + test("generics, overloads, narrowing and conditional types", async () => { using dir = project({ "a.ts": ` From 9baa579f26d2733c7ba6ec869407768a4e718568 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 13 Oct 2026 14:36:32 +0800 Subject: [PATCH 2/2] feat(check): prototype mapped recursion identity normalization Experimentally port microsoft/typescript-go#4913 (ea58062260), including its explicit indeterminate relation-depth contract while retaining complexity and physical stack safeguards. Add clean nested-Omit overlap, incompatible payload, recursive-alias termination, and cyclic-relation regressions; update the three existing depth expectations to the native 7.1 behavior. Declared 7.0.2 baseline remains unchanged pending full upgrade qualification. --- src/sema/check/explain_relation.rs | 22 ++---- src/sema/check/mod.rs | 3 - src/sema/check/relate.rs | 107 +++++++++++---------------- src/sema/check/shape.rs | 5 +- test/cli/check/check.test.ts | 115 ++++++++++++++++++++++++----- 5 files changed, 151 insertions(+), 101 deletions(-) diff --git a/src/sema/check/explain_relation.rs b/src/sema/check/explain_relation.rs index 3b993b671f93..71ce12e40da3 100644 --- a/src/sema/check/explain_relation.rs +++ b/src/sema/check/explain_relation.rs @@ -10,7 +10,7 @@ use super::explain::Line; use super::explain::NOWHERE; -use super::relate::{REC_BOTH, Relater, Relation, STACK_DEPTH_OVERFLOW, STATE_NONE, Ternary}; +use super::relate::{REC_BOTH, Relater, Relation, STATE_NONE, Ternary}; use super::related::Place; use super::*; use std::rc::Rc; @@ -325,7 +325,7 @@ impl<'p, 's> Checker<'p, 's> { is_related } - /// `isTypeRelatedTo`. `Err(code)`: the comparison overflowed (`r.overflow`); `code` is 2859 (complexity) or 2321 (stack depth). + /// `isTypeRelatedTo`. `Err(2859)`: the comparison exceeded the complexity limit. /// `is_trial`: tsgo has no comparison at this point, see `Relater::caches_failures`. pub(super) fn try_is_type_related_to( &mut self, @@ -334,23 +334,15 @@ impl<'p, 's> Checker<'p, 's> { relation: Relation, is_trial: bool, ) -> Result { - self.relation_too_deep = false; let too_complex_before = std::mem::replace(&mut self.relation_too_complex, false); self.is_trial_comparison = is_trial; let is_related = self.related(source, target, relation); self.is_trial_comparison = false; let is_too_complex = std::mem::replace(&mut self.relation_too_complex, too_complex_before); - let mut is_too_deep = self.relation_too_deep; - // "such that we don't attempt the overflowing operation again" - if !is_related && !is_too_deep && !is_trial { - let (key, _) = self.relation_key(source, target, relation, STATE_NONE, false); - let entry = self.p.relations.get(&self.task, &key); - is_too_deep = entry.is_some_and(|entry| entry & STACK_DEPTH_OVERFLOW != 0); - } - match () { - _ if is_too_complex => Err(2859), - _ if is_too_deep => Err(2321), - _ => Ok(is_related), + if is_too_complex { + Err(2859) + } else { + Ok(is_related) } } @@ -1103,7 +1095,7 @@ impl<'p, 's> Checker<'p, 's> { let gives_way = !is_conversion_or_interface_implementation_message(message); let is_already_reported = match r.get_chain_message(0) { Some(2353 | 2561) => true, - Some(2859 | 2321 | 4104) => r.chain_args_match(&names), + Some(2859 | 4104) => r.chain_args_match(&names), Some(2741) => gives_way && r.chain_args_match(&[None, names[0], names[1]]), Some(2740 | 2739) => gives_way && r.chain_args_match(&names), _ => false, diff --git a/src/sema/check/mod.rs b/src/sema/check/mod.rs index cf8131e69721..f4d1cf041074 100644 --- a/src/sema/check/mod.rs +++ b/src/sema/check/mod.rs @@ -732,7 +732,6 @@ impl<'s> Program<'s> { inherited_names: [0; 4], cond_distributive_memo: Default::default(), relation_too_complex: false, - relation_too_deep: false, current_source_element: None, is_type_checked: false, is_emitting: false, @@ -1411,8 +1410,6 @@ pub struct Checker<'p, 's> { cond_distributive_memo: FxHashMap>, /// Set when a comparison exhausts `Relater::relation_count` (2859). The caller clears it before comparing. pub(super) relation_too_complex: bool, - /// Whether the last `checkTypeRelatedToEx` reached 100 nested comparisons (2321). - pub(super) relation_too_deep: bool, /// `c.currentNode`, as `checkSourceElement` and `checkDeferredNode` set it. See `current_node`. pub(super) current_source_element: Option, /// `NodeCheckFlagsTypeChecked` of `task.file`: `check_file` has finished it. diff --git a/src/sema/check/relate.rs b/src/sema/check/relate.rs index 3d83800906ac..2ba2d52554a2 100644 --- a/src/sema/check/relate.rs +++ b/src/sema/check/relate.rs @@ -96,8 +96,6 @@ pub(super) const FAILED: u8 = 2; const REPORTS_UNMEASURABLE: u8 = 8; const REPORTS_UNRELIABLE: u8 = 16; pub(super) const COMPLEXITY_OVERFLOW: u8 = 32; -pub(super) const STACK_DEPTH_OVERFLOW: u8 = 64; -const OVERFLOW: u8 = COMPLEXITY_OVERFLOW | STACK_DEPTH_OVERFLOW; // VarianceFlags pub(super) const INVARIANT: u8 = 0; @@ -229,8 +227,6 @@ pub(super) struct Relater { pub(super) overflow: bool, /// A cached `COMPLEXITY_OVERFLOW` entry answered one of the comparisons. hit_cached_overflow: bool, - /// `len(r.sourceStack)` or `len(r.targetStack)` reached 100, which is why `overflow` is set. - is_too_deep: bool, /// `related` has already applied its own rules to `top_source` and `top_target`: no simple rule /// relates them, and they are not an object type and a primitive. Consumed by the first /// comparison. @@ -275,7 +271,6 @@ impl Relater { expanding: 0, overflow: false, hit_cached_overflow: false, - is_too_deep: false, is_from_related: false, top_key: None, relation_count: 2_000_000, @@ -1079,10 +1074,9 @@ impl<'p, 's> Checker<'p, 's> { r.caches_failures = is_trial; let result = self.is_related_to_ex::(&mut r, source, target, REC_BOTH, STATE_NONE); let overflow = r.overflow; - // `relationCount <= 0`. Running out of nesting depth or native stack is not a complexity overflow. + // `relationCount <= 0`. Running out of native stack is not a complexity overflow. let is_too_complex = overflow && r.relation_count <= 0 && self.cycles == r.cycles; let hit_cached_overflow = r.hit_cached_overflow; - let is_too_deep = overflow && std::mem::take(&mut r.is_too_deep) && !is_trial; r.is_from_related = false; r.top_key = None; r.maybe_keys.clear(); @@ -1098,17 +1092,12 @@ impl<'p, 's> Checker<'p, 's> { } self.free_relaters.push(r); self.end_comparison(is_outermost); - self.relation_too_deep = is_too_deep; - if is_too_complex || is_too_deep { - let (code, kind) = match is_too_complex { - true => (2859, COMPLEXITY_OVERFLOW), - false => (2321, STACK_DEPTH_OVERFLOW), - }; - self.set_relation_overflow(source, target, relation, kind); + if is_too_complex { + self.set_relation_overflow(source, target, relation, COMPLEXITY_OVERFLOW); self.relation_too_complex |= is_too_complex; if !is_trial { let types = [Arg::Type(source), Arg::Type(target)]; - self.error_about_comparison_at_current_node(code, &types); + self.error_about_comparison_at_current_node(2859, &types); } } else if hit_cached_overflow && !overflow && !result.holds() { // `reportRelationError`: the overflow replaces the relation error only if it was recorded for `source` and `target`. @@ -3750,16 +3739,11 @@ impl<'p, 's> Checker<'p, 's> { }; if let Some(entry) = cached // A cached failure is recomputed to produce its error elaboration. - && !(REPORT && entry & FAILED != 0 && entry & OVERFLOW == 0) + && !(REPORT && entry & FAILED != 0 && entry & COMPLEXITY_OVERFLOW == 0) { self.reliability |= entry & (REPORTS_UNMEASURABLE | REPORTS_UNRELIABLE); - if REPORT && entry & OVERFLOW != 0 { - let code = if entry & COMPLEXITY_OVERFLOW != 0 { - 2859 - } else { - 2321 - }; - self.report_error(r, code, &[Arg::Type(source), Arg::Type(target)]); + if REPORT && entry & COMPLEXITY_OVERFLOW != 0 { + self.report_error(r, 2859, &[Arg::Type(source), Arg::Type(target)]); } if entry & COMPLEXITY_OVERFLOW != 0 { // The comparison was aborted when it ran. @@ -3787,9 +3771,12 @@ impl<'p, 's> Checker<'p, 's> { return Ternary::MAYBE; } } - let is_too_deep = r.source_stack.len() == 100 || r.target_stack.len() == 100; - if is_too_deep || self.is_stack_low() { - r.is_too_deep |= is_too_deep; + if r.source_stack.len() == 100 || r.target_stack.len() == 100 { + // The relation depth limit is indeterminate, not proof that the types differ. + r.maybe_keys_set.remove(&key); + return Ternary::MAYBE; + } + if self.is_stack_low() { r.maybe_keys_set.remove(&key); r.overflow = true; return Ternary::FALSE; @@ -3850,7 +3837,7 @@ impl<'p, 's> Checker<'p, 's> { // A result that is false under assumptions is false without them, and so is one that // an overflow has cut off. Not so where the native stack ran low, which is no overflow // in typescript-go. - let is_cut_short = r.overflow && !r.is_too_deep && r.relation_count > 0; + let is_cut_short = r.overflow && r.relation_count > 0; if r.caches_failures { if !is_cut_short { r.failed.insert(key); @@ -3877,13 +3864,11 @@ impl<'p, 's> Checker<'p, 's> { source: TypeId, target: TypeId, ) -> Option { - let (code, kind) = match () { - _ if r.relation_count <= 0 => (2859, COMPLEXITY_OVERFLOW), - _ if r.is_too_deep => (2321, STACK_DEPTH_OVERFLOW), - _ => return None, - }; - self.set_relation_overflow(source, target, r.relation, kind); - Some(code) + if r.relation_count > 0 { + return None; + } + self.set_relation_overflow(source, target, r.relation, COMPLEXITY_OVERFLOW); + Some(2859) } /// `relation.set(id, RelationComparisonResultFailed|kind)` of `checkTypeRelatedToEx`. It @@ -3964,13 +3949,11 @@ impl<'p, 's> Checker<'p, 's> { if stack.len() < max_depth { return false; } - let (t, data) = self.mapped_target_with_symbol(t); - if let TypeData::Intersection(parts) = data - && parts + let (t, data) = self.recursion_identity_target(t); + if let TypeData::Intersection(parts) = data { + return parts .iter() - .any(|&p| self.is_deeply_nested_type(p, stack, max_depth)) - { - return true; + .any(|&p| self.is_deeply_nested_type(p, stack, max_depth)); } let identity = self.recursion_identity_as(t, data); let mut count = 0; @@ -4038,11 +4021,15 @@ impl<'p, 's> Checker<'p, 's> { false } - /// `recursion_identity`. `NOT_PLAIN` for a mapped type, which uses the type it is applied to, - /// and for an intersection, which uses its members. + /// `recursion_identity`. Mapped types and indexed accesses use their target; intersections use their members. fn plain_recursion_identity(&self, t: TypeId) -> RecursionId { let data = self.data(t); - if is_mapped_kind(data) || matches!(data, TypeData::Intersection(_)) { + if is_mapped_kind(data) + || matches!( + data, + TypeData::Intersection(_) | TypeData::IndexedAccess { .. } + ) + { NOT_PLAIN } else { self.recursion_identity_as(t, data) @@ -4060,18 +4047,17 @@ impl<'p, 's> Checker<'p, 's> { } } - /// `getMappedTargetWithSymbol`: the type a homomorphic mapped type is applied to, through any - /// number of nested ones, provided that type has a symbol. `Id<{ x: .. }>` is then - /// distinguished by the type literal it is applied to. Returned with its `TypeData`. + /// `getRecursionIdentityTarget`: indexed accesses and mapped types share their target's identity. #[inline] - fn mapped_target_with_symbol(&mut self, t: TypeId) -> (TypeId, &'p TypeData<'p>) { - let data = self.data(t); - if is_mapped_kind(data) { - let t = self.target_with_symbol_of_mapped(t); - (t, self.data(t)) - } else { - (t, data) + fn recursion_identity_target(&mut self, mut t: TypeId) -> (TypeId, &'p TypeData<'p>) { + while let TypeData::IndexedAccess { obj, .. } = self.data(t) { + t = *obj; + } + if is_mapped_kind(self.data(t)) { + // This walk cannot return an indexed access; do not restart its circular-alias limit. + t = self.target_with_symbol_of_mapped(t); } + (t, self.data(t)) } fn target_with_symbol_of_mapped(&mut self, of: TypeId) -> TypeId { @@ -4118,7 +4104,7 @@ impl<'p, 's> Checker<'p, 's> { /// `hasMatchingRecursionIdentity` fn has_matching_recursion_identity(&mut self, t: TypeId, identity: RecursionId) -> bool { - let (t, data) = self.mapped_target_with_symbol(t); + let (t, data) = self.recursion_identity_target(t); match data { TypeData::Intersection(parts) => parts .iter() @@ -4163,8 +4149,9 @@ impl<'p, 's> Checker<'p, 's> { } /// `getRecursionIdentity` - pub(super) fn recursion_identity(&self, t: TypeId) -> RecursionId { - self.recursion_identity_as(t, self.data(t)) + pub(super) fn recursion_identity(&mut self, t: TypeId) -> RecursionId { + let (t, data) = self.recursion_identity_target(t); + self.recursion_identity_as(t, data) } /// `data`: the `TypeData` of `t`. @@ -4214,14 +4201,6 @@ impl<'p, 's> Checker<'p, 's> { (5, flags.len() as u32, hasher.finish() as u32) } TypeData::Cond { file, node, .. } => (2, file.0, node.0), - TypeData::IndexedAccess { obj, .. } => { - // The leftmost object type: `A` in `A[P1][P2][P3]`. - let mut of = *obj; - while let TypeData::IndexedAccess { obj, .. } = self.data(of) { - of = *obj; - } - (0, of.0, 0) - } _ => (0, t.0, 0), } } diff --git a/src/sema/check/shape.rs b/src/sema/check/shape.rs index 30b36439bc26..2a982e65c7a5 100644 --- a/src/sema/check/shape.rs +++ b/src/sema/check/shape.rs @@ -6514,7 +6514,10 @@ impl<'p, 's> Checker<'p, 's> { // `getResolvedBaseConstraint`: a `StructuredType` is a `ConstrainedType`, so the depth test applies to it, and // `resolvedBaseConstraint` keeps `noConstraintType` or the type itself from the first visit on. let is_cut = depth >= 10 - && (depth >= 50 || self.constraint_stack.contains(&self.recursion_identity(ty))); + && (depth >= 50 || { + let identity = self.recursion_identity(ty); + self.constraint_stack.contains(&identity) + }); let result = if is_cut { None } else if is_object_type { diff --git a/test/cli/check/check.test.ts b/test/cli/check/check.test.ts index 5445ff7aa6e2..9ae9b40c3790 100644 --- a/test/cli/check/check.test.ts +++ b/test/cli/check/check.test.ts @@ -2806,6 +2806,86 @@ const kind: number = Shape.kind; `); }); + test("recursive mapped type identities preserve assertion overlap", async () => { + using dir = project({ + "a.ts": ` +type Raw = { kind: "read"; database: {}; value: string }; +type Current = Omit & { kind: "current"; source?: string }; +type Prepared = I extends unknown ? Omit : never; +type Input = Prepared>; +function repro(kind: K, input: I) { + return { kind, ...input } as Extract; +} +function incompatible(kind: K) { + // @ts-expect-error: the payload remains incompatible after mapped-type normalization. + return { kind, value: 1 } as Extract; +} +declare const wrong: { kind: "wrong"; value: number }; +// @ts-expect-error: unrelated discriminants and payloads do not overlap. +const rejected = wrong as Input; +type Wrapper = { [P in keyof T]: T[P] }; +type Indexed = Wrapper>[keyof T]; +declare const nested: Indexed<{ value: Current }>; +const nestedValue: string = nested.value; +// @ts-expect-error: resolving an indexed homomorphic type preserves the payload. +const badNestedValue: number = nested.value; +`, + }); + expect(await check(dir)).toEqual({ + stdout: "", + stderr: "✓ No type errors in 1 file [time]", + exitCode: 0, + }); + }); + + test("recursive mapped aliases terminate", async () => { + using dir = project({ + "a.ts": ` +// @ts-expect-error: the mutually mapped aliases have a circular key constraint. +type Left = { [K in keyof Right]: Right[K] }; +// @ts-expect-error: the mutually mapped aliases have a circular key constraint. +type Right = { [K in keyof Left]: Left[K] }; +// @ts-expect-error: the invalid alias is not a usable generic. +declare const circular: Left<{ value: string }>; +// @ts-expect-error: the invalid alias is not a usable generic. +const projected: Right<{ value: string }> = circular; +type Recursive = { [K in keyof T]: Recursive }; +interface Self { name: string; next?: Self; } +declare const recursive: Recursive; +const compatible: Recursive> = recursive; +export {}; +`, + }); + expect(await check(dir)).toEqual({ + stdout: "", + stderr: "✓ No type errors in 1 file [time]", + exitCode: 0, + }); + }); + + test("recursive relation depth preserves compatible cycles", async () => { + let source = ""; + for (const prefix of ["A", "B", "C"]) { + for (let i = 0; i < 40; i++) { + const name = prefix === "C" && i === 1 ? "number" : "string"; + source += `interface ${prefix}${i} { name: ${name}; next?: ${prefix}${(i + 1) % 40}[]; }\n`; + } + } + using dir = project({ + "a.ts": source + ` +declare const a: A0; +const accepted: B0 = a; +// @ts-expect-error: the recursive shape differs near the root. +const rejected: C0 = a; +`, + }); + expect(await check(dir)).toEqual({ + stdout: "", + stderr: "✓ No type errors in 1 file [time]", + exitCode: 0, + }); + }); + test("uniform union literals keep narrowing in mutable locations", async () => { using dir = project({ "a.ts": ` @@ -2893,7 +2973,11 @@ function distinctEnums(value: N1.E | N2.E) { } `, }); - expect(await check(dir)).toEqual({ stdout: "", stderr: "", exitCode: 0 }); + expect(await check(dir)).toEqual({ + stdout: "", + stderr: "✓ No type errors in 1 file [time]", + exitCode: 0, + }); }); test("generics, overloads, narrowing and conditional types", async () => { @@ -6119,7 +6203,7 @@ a[k] += "s"; expect(exitCode).toBe(1); }); - test("a comparison without an error node that exceeds the depth limit is reported at the type reference", async () => { + test("recursive mapped constraints do not fail at the relation depth limit", async () => { using dir = project({ "a.ts": `type UnionToIntersect = (U extends unknown ? (arg: U) => 0 : never) extends (arg: infer I) => 0 ? I : never; type UnionLast = UnionToIntersect 0 : never> extends (x: infer L) => 0 ? L : never; @@ -6131,11 +6215,11 @@ type Wrap = T extends [] ? never : T; export type Result = Wrap }[T]>, Schema[]>>; `, }); - const { stdout, exitCode } = await check(dir); - expect(stdout).toMatchInlineSnapshot( - `"a.ts(8,40): error TS2321: Excessive stack depth comparing types 'UnionToTuple<{ [K in T]: Literal; }[T], UnionLast<{ [K in T]: Literal; }[T]>>' and 'Schema[]'."`, - ); - expect(exitCode).toBe(1); + expect(await check(dir)).toEqual({ + stdout: "", + stderr: "✓ No type errors in 1 file [time]", + exitCode: 0, + }); }); test("a call of a variable without an annotation, in a loop that assigns the result to it", async () => { @@ -8086,7 +8170,7 @@ export declare const o: { readonly L: unique symbol }; // 200 comparisons deep. The stack frames of a debug build are several times larger, and it runs out of stack first. test.skipIf(isDebug || isASAN)( - "a comparison that exceeds the depth limit while its error is elaborated", + "deep relation elaboration preserves an incompatible payload", async () => { using dir = project({ "a.ts": `interface T0 { a: A; next: T1 | null; take(x: T1): void } @@ -8145,11 +8229,8 @@ export const v: T0 = null! as T0; }); const { stdout, exitCode } = await check(dir); expect(stdout).toMatchInlineSnapshot(` - "a.ts(51,14): error TS2321: Excessive stack depth comparing types 'T0' and 'T0'. - a.ts(51,14): error TS2321: Excessive stack depth comparing types 'T49' and 'T49'. - a.ts(52,14): error TS2322: Type 'T0' is not assignable to type 'T0'. - Types of property 'a' are incompatible. - Type 'unknown' is not assignable to type 'string'." + "a.ts(52,14): error TS2322: Type 'T0' is not assignable to type 'T0'. + Type 'unknown' is not assignable to type 'string'." `); expect(exitCode).toBe(1); }, @@ -17697,11 +17778,9 @@ describe.concurrent("--check", () => { expect(exitCode).toBe(0); }); - // The limit of 5,000,000 instantiations is for one statement or expression. Under `exactOptionalPropertyTypes` one of - // these comparisons takes about 45,000. After 110 of them every instantiation in the file gave the error type, to - // which anything is assignable. + // The instantiation limit is per statement; repeated deep comparisons must not hide a later payload error. test.skipIf(isDebug || isASAN).each([130])( - "%i comparisons that reach the depth limit do not hide the next error", + "%i deep comparisons do not hide the next error", async n => { using dir = project({ "tsconfig.json": JSON.stringify({ @@ -17720,7 +17799,7 @@ describe.concurrent("--check", () => { const errors = stdout .split("\n") .flatMap(line => /^index\.ts\((\d+),\d+\): error (TS\d+)/.exec(line)?.slice(1, 3).join(" ") ?? []); - expect(errors).toEqual([...range(n).map(i => `${i + 2} TS2321`), `${n + 2} TS2322`]); + expect(errors).toEqual([...range(n).map(i => `${i + 2} TS2322`), `${n + 2} TS2322`]); expect(exitCode).toBe(1); }, );