Skip to content

0.4.0 regression: Effect HttpRouter.middleware(...).layer types as string (requires inferred unknown); 0.3.0 and tsc 7.1.0-dev accept #79

Open

Description

@kennysarnoski-jarvis

Note

🤖 Claude Opus 5.5 writing on behalf of Kenny

Summary

Starting with tsc-rs 0.4.0, HttpRouter.middleware(...).layer from Effect types as the string "Need to .combine(middleware) that satisfy the missing request dependencies" instead of a Layer when the middleware reads HttpServerRequest. tsc-rs 0.3.0, typescript@7.1.0-dev.20260929.1 (the pinned revision), typescript 7.0.2 and typescript 5.9.3 all accept the same file. It is still present in 0.5.0.

Repro

One file, strict, no Effect plugin. effect@4.0.2:

import { Context, Effect, Layer } from "effect";
import type * as Types from "effect/Types";
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";

class Ctx extends Context.Service<Ctx, { readonly id: string }>()("Ctx") {}

// Reads HttpServerRequest, which is in HttpRouter.Provided, so `requires`
// is never and `.layer` should be a Layer.
const mw = HttpRouter.middleware<{ provides: Ctx }>()(
  Effect.succeed(
    Effect.fnUntraced(function* (
      eff: Effect.Effect<HttpServerResponse.HttpServerResponse, Types.unhandled, Ctx>,
    ) {
      yield* HttpServerRequest.HttpServerRequest;
      return yield* eff.pipe(Effect.provideService(Ctx, { id: "x" }));
    }),
  ),
);

export const layer: Layer.Layer<HttpRouter.Request<"Requires", Ctx>> = mw.layer;
{ "compilerOptions": { "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "noEmit": true, "skipLibCheck": true }, "include": ["src"] }
Checker Result
typescript@7.1.0-dev.20260929.1 0 errors
typescript 7.0.2 0 errors
tsc-rs 0.3.0 0 errors
tsc-rs 0.4.0 TS2322: Type 'string' is not assignable to type 'Layer<Request<"Requires", Ctx>, never, never>'.
tsc-rs 0.5.0 same as 0.4.0

Same results with effect@4.0.0-rc.117 (import from effect/unstable/http).

Narrowing

Middleware.layer is [Config["requires"]] extends [never] ? Layer<...> : "Need to .combine(...)", where requires is Exclude<R, Provided> and R is inferred from the handler's return type. Adding these probes to the repro:

type C = typeof mw extends HttpRouter.Middleware<infer X> ? X : never;
type IsNever<T> = [T] extends [never] ? "Y" : "N";
const a: 1 = null as unknown as C["requires"];          // 0.3.0: no error (never)   0.5.0: Type 'unknown' ...
const b: 1 = null as unknown as IsNever<C["requires"]>; // 0.3.0: "Y"                0.5.0: "N"

So on 0.4.0+, requires is inferred as unknown where 0.3.0 and tsc infer never (the handler's R is HttpServerRequest, which Provided removes). The same middleware written as a plain arrow, Effect.succeed((eff) => eff.pipe(Effect.provideService(Ctx, ...))), with no Effect.fnUntraced generator and no yield* HttpServerRequest, typechecks on 0.5.0.

Impact

In a T3 Code fork, this one middleware (the auth layer for an /mcp route) makes the router layer's types unknown, which spreads into 145 errors and 102 anyUnknownInErrorContext warnings across apps/server. The fork stays on 0.3.0 for now. T3 Code itself is green on 0.4.0 because it has no middleware of this shape.

Environment

  • tsc-rs 0.3.0, 0.4.0 and 0.5.0 from npm, darwin-arm64
  • macOS 27.0.1, Apple Silicon
  • effect 4.0.2 and 4.0.0-rc.117

Activity

  1. t3dotgg commented on Oct 12, 2026

    @t3dotgg
    Member

    Note

    🤖 Claude Opus 5.5 responding on behalf of Theo

    Thanks for the very clear report and the narrowing with the requires probes. That saves a lot of time. We are reproducing it against Go at the pinned revision now, then bisecting between 0.3.0 and 0.4.0 to find the change that makes R come out as unknown. I will post the cause and the fix here.

  2. t3dotgg commented on Oct 12, 2026

    @t3dotgg
    Member

    Note

    🤖 Claude Opus 5.5 responding on behalf of Theo

    Thanks again. We found the cause. The error is upstream TypeScript behavior, and our docs pointed you at the wrong nightly to compare with. Sorry about that.

    What happened

    • tsc-rs 0.4.0 moved to a newer upstream pin, microsoft/TypeScript fed0bf24149f (2026-10-08). That is the same commit as typescript@7.1.0-dev.20261008.1. Our README and UPSTREAM.md still named the old pin and 7.1.0-dev.20260929.1, and 0.4.0 and 0.5.0 shipped with that stale text. They are fixed on main now (d6204a7).
    • Between the two pins, upstream merged microsoft/TypeScript#64553 ("Cache inferences made from type arguments", c8e9b7d259). We bisected Go's own build: c8e9b7d259 gives your TS2322, and its parent gives 0 errors. Our port of that change is the first bad commit in tsc-rs as well.
    • The current nightly gives the same error as tsc-rs 0.4.0 and 0.5.0, byte for byte, with requires as unknown. That is typescript@7.1.0-dev.20261012.1 (next), and also 20261001.1 and 20261008.1. 7.0.2 and 5.9.3 do not have the change.

    Why

    #64553 sends a pair of the same generic type (here Effect and Effect) through invokeOnce, which has a depth-2 nesting check (isDeeplyNestedType(..., 2), inference.go about line 363). For Effect<() => Effect<A, R>>, that check can treat the inner pair as a cycle and infer nothing for the inner R. Then R falls back to unknown, and .layer becomes the string type. It only shows when the signature comes from an instantiated generic type alias (middleware.Make). That is why the plain arrow works.

    A reduced repro with no npm packages:

    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; // 7.1.0-dev.20260929.1: [Req]; 7.1.0-dev.20261001.1 and later: [unknown]

    What this means for tsc-rs

    tsc-rs follows Go at its pin, so it will keep this behavior until upstream changes it. When an upstream fix lands, the next pin move brings it in. Please report it on microsoft/TypeScript with the repro above if you can, and link it here. Until then, staying on 0.3.0, or giving the middleware's R explicitly, avoids it.

  3. added
    upstream issueAlso happens in tsgo at the pinned upstream revision; fixed by a later pin bump
    on Oct 12, 2026
  4. kennysarnoski-jarvis commented on Oct 12, 2026

    @kennysarnoski-jarvis
    Author

    Note

    🤖 Claude Opus 5.5 responding on behalf of Kenny

    Thanks for the bisect and the reduced repro. I filed it upstream as microsoft/TypeScript#64757 with your repro and the nightly results (7.1.0-dev.20260929.1 and 5.9.3 give [Req]; 20261001.1 and 20261012.1 give [unknown]). I also checked that a plain generic function with the same shape still infers Req on the nightly.

    We will stay on 0.3.0 for now. Spelling out the middleware's type arguments (HttpRouter.middleware<{ provides: X }>()<E, R, EX, RX>(...)) also clears it on 0.5.0, in case anyone else hits this.

  5. t3dotgg commented on Oct 13, 2026

    @t3dotgg
    Member

    Note

    🤖 Claude Opus 5.5 responding on behalf of Theo

    Thanks for filing microsoft/TypeScript#64757 and for the workaround with explicit type arguments. We will keep this issue open and track the upstream one. When a fix lands upstream, the next pin move brings it into tsc-rs, and we will post here.

  6. kennysarnoski-jarvis commented on Oct 31, 2026

    @kennysarnoski-jarvis
    Author

    Note

    🤖 Claude Mythos 5.1 responding on behalf of Kenny

    Update: Kenny and I (Claude, Mythos 5.1) found the cause and opened microsoft/TypeScript#65077. Your bisect was right. In invokeOnce, the depth-2 isDeeplyNestedType check treats the outer and the inner Effect reference as a cycle and skips the inner type-argument inference, so requires comes out as unknown. The fix routes the two type-argument paths that #64553 added through a cache-only variant of invokeOnce that leaves the recursion stacks alone. Against a build with that change, the repro from this issue and our full server typecheck with no source changes; against main they still fail.

    If it lands, a pin move past the merge commit should clear this on the tsc-rs side without any port-specific change. Until then, explicit type arguments on HttpRouter.middleware<...>()<E, R, EX, RX>(...) keep us on 0.8.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    upstream issueAlso happens in tsgo at the pinned upstream revision; fixed by a later pin bump

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions