Skip to content

Regression in 7.1 (#64553): R inferred as unknown through a generic type alias call signature with a nested same-type argument #64757

Open

Description

@kennysarnoski-jarvis

Note

🤖 Claude Opus 5.5 writing on behalf of Kenny

🔎 Search Terms

inference unknown, generic type alias call signature, generator, invokeOnce, isDeeplyNestedType, #64553, cache inferences type arguments, regression 7.1

🕗 Version & Regression Information

⏯ Playground Link

No playground link. The code below is self-contained, with no dependencies.

💻 Code

interface Effect<out A, out R = never> {
  readonly _A: () => A;
  readonly _R: () => R;
  [Symbol.iterator](): Iterator<Effect<A, R>, A>;
}
declare function succeed<A>(value: A): Effect<A>;
declare function gen<Eff extends Effect<any, any>, AEff>(
  body: () => Generator<Eff, AEff, never>,
): () => Effect<AEff, Eff extends Effect<infer _A, infer R> ? R : never>;

interface Req { readonly url: string }
declare const req: Effect<Req, Req>;

type Make<P> = <R>(m: Effect<() => Effect<string, R>>) => [R];
declare const make: Make<unknown>;

const r = make(succeed(gen(function* () {
  yield* req;
  return "";
})));
const check: [1] = r; // error message shows what R was inferred as

tsconfig.json: { "compilerOptions": { "target": "ES2023", "module": "NodeNext", "strict": true, "noEmit": true } }

Version r
5.9.3 [Req]
7.1.0-dev.20260929.1 [Req]
7.1.0-dev.20261001.1 [unknown]
7.1.0-dev.20261012.1 [unknown]

🙁 Actual behavior

R is inferred as unknown, so r is [unknown].

🙂 Expected behavior

R is inferred as Req, so r is [Req], as in 5.9.3, 7.0.2 and 7.1.0-dev.20260929.1.

Additional information about the issue

The tsc-rs maintainers traced it to the depth-2 nesting check in invokeOnce (isDeeplyNestedType(..., 2), about line 363 of inference.go). #64553 now sends the Effect/Effect pair through it. For Effect<() => Effect<A, R>>, the check treats the inner pair as a cycle and infers nothing for the inner R. It only happens when the signature comes from an instantiated generic type alias (Make<P> above). Calling a plain generic function with the same shape infers Req.

Real-world impact: this is the shape of Effect 4's HttpRouter.middleware<...>()(...), whose signature is the generic alias middleware.Make<Provides, Handles>. A middleware written with Effect.fn or Effect.gen that reads the request gets R = unknown, and .layer resolves to the string type "Need to .combine(middleware) that satisfy the missing request dependencies". In one Effect app this cascades into 145 errors.

Activity

  1. jclaessens97 commented on Oct 21, 2026

    @jclaessens97

    Another shape of this regression, from Prisma's nested select results (findUnique/findMany with relations two levels deep). Same boundary: fine on 6.0.3, 7.0.2 and 7.1.0-dev.20260929.1; unknown from 7.1.0-dev.20261001.1 through 7.1.0-dev.20261020.3. Here the generic alias is a parameter type, not a call signature; the same type written inline still infers.

    // Cut down from Prisma's GetFindResult (the result type of findUnique/findMany with a nested select).
    type Payload = { scalars: object; objects: object };
    
    type FindResult<P extends Payload, A> = A extends { select: infer S extends object }
      ? {
          [K in keyof S]: S[K] extends object
            ? P extends { objects: { [k in K]: (infer O)[] } }
              ? FindResult<O & Payload, S[K]>[]
              : never
            : P['scalars'][K & keyof P['scalars']];
        }
      : never;
    
    type MessagePayload = { objects: {}; scalars: { body: string } };
    type ConversationPayload = { objects: { messages: MessagePayload[] }; scalars: {} };
    type ThreadPayload = { objects: { conversations: ConversationPayload[] }; scalars: {} };
    
    declare function findThread<A>(args: A): FindResult<ThreadPayload, A>;
    
    const thread = findThread({
      select: { conversations: { select: { messages: { select: { body: true } } } } },
    });
    
    type ThreadRow<M> = { conversations: Array<{ messages: Array<M> }> };
    
    declare function firstMessageViaAlias<M>(thread: ThreadRow<M>): M;
    declare function firstMessageInline<M>(thread: { conversations: Array<{ messages: Array<M> }> }): M;
    
    export const inline: string = firstMessageInline(thread).body;
    export const viaAlias: string = firstMessageViaAlias(thread).body; // TS2571 on 7.1

    tsc --strict --noEmit repro.ts, no other options needed.

    Version M inline M via alias
    6.0.3 { body: string } { body: string }
    7.0.2 { body: string } { body: string }
    7.1.0-dev.20260929.1 { body: string } { body: string }
    7.1.0-dev.20261001.1 { body: string } unknown (TS2571)
    7.1.0-dev.20261020.3 { body: string } unknown (TS2571)

    One level of nesting through an alias (type OneLevel<M> = { messages: Array<M> }, called with thread.conversations[0]) still infers { body: string } on every version.

  2. kennysarnoski-jarvis commented on Oct 21, 2026

    @kennysarnoski-jarvis
    Author

    Note

    🤖 Claude Opus 5.5 responding on behalf of Kenny

    Thanks for the second repro. I ran it locally (--strict --noEmit, unmodified) and got the same boundary:

    Compiler Result
    typescript@5.9.3 OK
    typescript@7.1.0-dev.20260929.1 OK
    typescript@7.1.0-dev.20261001.1 TS2571 on viaAlias
    typescript@7.1.0-dev.20261020.3 TS2571 on viaAlias

    The original report shows up via Effect's HttpRouter.middleware(...) (R becomes unknown through the middleware.Make alias). This one hits Prisma's GetFindResult with a generic alias as a parameter type. In both, inference fails only when the type goes through an instantiated generic alias at two levels of nesting, and both start with #64553.

    Downstream, the regression also reaches tools that pin a 7.1 nightly. For example, tsc-rs 0.4.0+ (pingdotgg/ts-rust#79) shows it on both repros, and 0.3.0, which still pins 20260929.1, does not.

  3. artile commented on Oct 31, 2026

    @artile

    Note

    🤖 Claude Opus 5.5 writing on behalf of a TypeScript 7.1 user. All results below come from local runs.

    Third real-world shape, with two details that may help:

    1. The trigger is not only a generic alias. A method of an instantiated generic interface fails too.
    2. The outer type argument must appear inside the nested type. If F is declared but not used in the parameter, inference works.

    The boundary is also narrower: 7.1.0-dev.20260930.4 passes, and 7.1.0-dev.20261001.1 fails. 7.1.0-beta and 7.1.0-dev.20261031.1 still fail.

    interface Eff<out A, out R> {
      readonly _a: A
      readonly _r: R
      [Symbol.iterator](): Generator<Eff<A, R>, A, never>
    }
    type ReqOf<E> = [E] extends [never] ? never : [E] extends [Eff<any, infer R>] ? R : never
    declare function fn<E extends Eff<any, any>, A, Args extends any[]>(
      body: (...args: Args) => Generator<E, A, never>,
    ): (...args: Args) => Eff<A, ReqOf<E>>
    declare function succeed<A>(a: A): Eff<A, never>
    
    interface S<F> { toLayer<Req>(build: Eff<(p: { n: number }) => Eff<F, Req>, never>): Req }
    declare const s: S<{ p: boolean }>
    const req = s.toLayer(succeed(fn(function* ({ n }) { return { p: n > 0 } })))
    const check: never = req

    tsconfig.json: { "compilerOptions": { "strict": true, "noEmit": true, "target": "esnext", "module": "esnext", "moduleResolution": "bundler", "skipLibCheck": true, "lib": ["esnext"] }, "files": ["a.ts"] }

    Parameter shape 7.0.2 7.1.0-dev.20260930.4 7.1.0-dev.20261001.1 7.1.0-dev.20261031.1
    interface S<F> method, nested Eff<F, Req> never never unknown unknown
    type Make<F> = <Req>(...), nested Eff<F, Req> never never unknown unknown
    interface S<F> method, F not used in the parameter never never never never
    Non-generic interface method never never never never
    Plain function never never never never
    Generic function <F, Req>, F inferred from another argument never never never never

    With a real requirement in the generator body, 7.0.2 and 20260930.4 infer that requirement, and 20261001.1 and later infer unknown.

    Real-world impact: this is Saga.toLayer of @alchemy.run/domain, called with a handler from Effect.fn. In one app, 3 sagas gave 137 TypeScript errors and 285 false Effect diagnostics. A workaround for this shape: return (payload) => run(payload) from the build effect, where run is the Effect.fn result.

  4. kennysarnoski-jarvis commented on Oct 31, 2026

    @kennysarnoski-jarvis
    Author

    Note

    🤖 Claude Mythos 5.1 responding on behalf of Kenny

    Kenny and I (Claude, Mythos 5.1) tracked this down and opened #65077 with a fix and a compiler test that covers the three shapes in this thread.

    Cause: #64553 routed inference from the type arguments of two references to the same generic type (or alias) through invokeOnce. Besides caching, invokeOnce pushes the pair onto the recursion stacks and reports a circularity when isDeeplyNestedType(..., 2) finds the same recursion identity twice on both the source and the target side. For Eff<() => Eff<A, R>, never> the outer and the inner reference share an identity, so the inner pair is treated as a cycle, inferFromTypeArguments never runs for it, and R falls back to unknown. Before #64553 that path had no recursion guard, and 5.9 and 7.0 still run it without one. That matches the boundary reported above (#64553 is in 7.1.0-dev.20261001.1, not in 20260930.4).

    The fix keeps the cache for those two paths and skips only the recursion check. With it, the alias call-signature repro, the Prisma nested select shape, the Saga.toLayer repro and Effect's HttpRouter.middleware(...).layer all infer as on 7.0.2, and go test ./... passes.

  5. added a commit that references this issue on Oct 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Needs InvestigationThis issue needs a team member to investigate its status.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions