Skip to content

[5.9 regression] as const readonly tuple inferred as mutable through a nested generic call under a conditional-type circular constraint #63666

Description

@xkatianx

🔎 Search Terms

readonly tuple stripped, as const widening, mutable tuple inference, circular constraint, F-bounded constraint, conditional type constraint, contextual inference, 5.9 regression

🕗 Version & Regression Information

⏯ Playground Link

https://www.typescriptlang.org/play/?ts=5.9.3#code/JYOwLgpgTgZghgYwgAgEIHsAeAeAKgPmQG8AoZZKCOAE3RABsBPZANznoFcIAuZXEgL4kwjAA4oAwnUjhsAJUIBeZHOQRMM6gGc0WbKBjRkAVUIB+E8l4gILaMLEoMmADLAA1hHlLdOKeAhZBXwSEmoIBHo4SmQYDhAEMGA6ZAAjPQIACjZOHj4ASl5nPBCwiKiYuISklJh5NQ1A7V83T298bN45QpVQ8MjolAQ6LTBkOF4icesOAFtUoyF+iqGRsdTJtJn5xdCAej3kQBlyZABWADoATiK9AG0pieQQOYWoZAEAGmItp5fFgF1CIAUAgoVFoDGYoygwFE4mosSg6FmyDAAAsUHAtABaYYgUYojiiegQEgHZCAUHIzucABznADMNxwlBodCYyHu01+Ozen2+Gy5r3egJIuPxUAAjMhlDBMulMJlbnAvql-uMdKKwPl8vtDpSpjkuLxmeC2RzHs9ue8vlN+RbBQJVQJkCCtHBZigDRidHBkNRgJREqTDuhUgArCJjejASBQdjITwQUQ6Y2sxgitYUABMUtimX17EN7KVaVVmOQGve2qDFN82CmKYhT144oA3KCWY2tLwAESo4Dd97A9sm5gAKlEiPEUCSEC0o-GIHhUZj7GrInEOi0HCgLGAdjbqeHh7AhOJWj20VjjG9MTgMEMiQg1HTeLGUDpOZlcrzTeQ4q+XbIAA5H2QHvGq5ZrFqOo1sUDamg82z2taPx2gCQ4gHQWIBtuWh7qsr6xqAYzADoMCgCSyyDLE8SJMkIDIAA5vU6iaDoxTxO4mEAO4gPgHQsF0PRyC+YoACw5oxspYAqxYqhBGrQdWlJwWCh5mkhiwobafw8oCzrlv6CAcBUkGEXAxGsMAPqgOEmBPtWiBIFoOiZJhZl+jUIBxuuED5MgpHjPQWjoLEFFlAMlS0V5yCoixjSLuxehyLcQGekBgICUJXSiW+pw5qi0nyoqyqluqUHakAA

💻 Code

interface Box<T> {
  readonly value: T
}
type Content<R> = R extends Box<infer U> ? U : never
type BoxLike<R> = Box<Content<R>>

declare function box<T>(value: T): Box<T>

declare function f<R extends BoxLike<R>>(v: R): R

declare const a: { a: number }
declare const b: { b: number }

// ❌ 5.9: Box<[{ a: number }, { b: number }]> — readonly stripped from the as-const tuple
// ✅ 5.8.3: Box<readonly [{ a: number }, { b: number }]>
const r1 = f(box([a, b] as const))

// ✅ { value: readonly [{ a: number }, { b: number }] } — same value as a direct
// object literal keeps readonly
const r2 = f({ value: [a, b] as const })

// ✅ Box<{ readonly n: 1; readonly s: "hi" }> — readonly *properties* and literal
// types survive; only readonly tuples/arrays are affected
const r3 = f(box({ n: 1, s: 'hi' } as const))

// ✅ Box<readonly [{ a: number }, { b: number }]> — non-recursive constraint is fine
declare function g<R extends Box<unknown>>(v: R): R
const r4 = g(box([a, b] as const))

// ✅ Box<readonly [{ a: number }, { b: number }]> — circular constraint via indexed
// access (no conditional type) is also fine
declare function h<R extends Box<R['value']>>(v: R): R
const r5 = h(box([a, b] as const))

🙁 Actual behavior

In r1, the as const tuple loses its readonly modifier: R is inferred as Box<[{ a: number }, { b: number }]> (mutable tuple). In 5.8.3 the same code inferred Box<readonly [{ a: number }, { b: number }]>.

All three ingredients are required to trigger it:

  1. the type parameter has a self-referential (F-bounded) constraint that goes through a conditional typeR extends Box<Content<R>> where Content<R> = R extends Box<infer U> ? U : never;
  2. the argument flows through a nested generic call (box(...)) rather than being a direct literal — r2 is fine — or a pre-typed variable;
  3. the inferred type contains a readonly tuple. Readonly object properties and literal types under the same as const survive (r3).

Removing any one ingredient preserves readonly: a closed constraint (r4) or an indexed-access circular constraint with no conditional type (r5) both infer Box<readonly [{ a: number }, { b: number }]>.

Ingredients 1 and 2 line up with the change described in #61668: since that PR, the return type of the inner box(...) call is checked against a contextual type in which the outer R has been instantiated to its constraint — here a conditional-type-based self-referential constraint — and somewhere in that inference path the tuple candidate's readonly modifier is dropped.

The same trigger also affects context-sensitive callback arguments (f2<R extends BoxLike<R>>(body: () => R) with an inline arrow or generator body), which is how this was originally hit: a Rust-style Result library exposing gen<Y, R extends ResultLike<R>>(body: () => Generator<Y, R>) inferred Result<[A, B], E> from a body that returned ok([a, b] as const).

🙂 Expected behavior

r1 infers Box<readonly [{ a: number }, { b: number }]>, as it did in 5.8.3 and consistent with r2, r4, and r5, which differ only in details that should not affect the tuple's readonly-ness. A const assertion produces a non-widening readonly tuple; inference should not convert it to a mutable one.

Note the failure mode is silent unsoundness rather than an inference error: r1.value.push(...) type-checks against a frozen-in-intent tuple.

Additional information about the issue

Possibly related: #62071 (another inference regression linked to #61668), #51377 (inference failure with T extends F<T>), #44821 (contextual inference at call sites with circular constraints).

Metadata

Metadata

Assignees

No one assigned

    Labels

    BugA bug in TypeScript

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions