Flat RPC client and AtomRpc query/mutation: type-check cost grows with RPCs in the group × tags called

Abierto
#10,396 0 comentarios 0 reacciones 1 asignado Ver en GitHub

Los mantenedores suelen responder en 1 día

@effect-bot ya está trabajando en esto.

Desde el 5/11/2026.

Evaluación

Este issue todavía no se ha evaluado.

Descripción

What version of Effect is running?

[email protected] (also seen on 4.0.5); TypeScript 7 (@typescript/[email protected]). The types involved are unchanged on main.

What steps can reproduce the bug?

RpcClient.RpcClient.Flat, AtomRpc's query and mutation, and Rpc.ExtractTag type each call with a distributive conditional over the whole RPC union (Rpcs extends Rpc.Rpc<infer _Tag extends Tag, …>). For every distinct tag a program calls, TypeScript evaluates that conditional against every RPC in the group. So type-check time and memory grow with distinct tags called × RPCs in the group: roughly quadratic for an app that calls most of its RPCs. AtomRpc always builds its client with flatten: true, so AtomRpc users can't switch to the object-shaped client, which is a mapped type computed once and stays cheap.

The script below writes a group of N generated RPCs and 300 calls in each style:

  • flat: yield* client("RpcK", payload) through an AtomRpc.Service
  • query: Client.query("RpcK", payload)
  • mutation: Client.mutation("RpcK")
  • methods: the object-shaped RpcClient.RpcClient, as client.RpcK(payload)
  • none: the group alone, with no calls
Reproduction (3 files, then npm install && bash bench.sh 600 300)

package.json

{
  "private": true,
  "type": "module",
  "dependencies": { "effect": "4.1.2" },
  "devDependencies": { "@typescript/native-preview": "7.0.0-dev.20260707.2" }
}

gen.mjs

// node gen.mjs <rpcs> <calls>: a group of <rpcs> RPCs, and <calls> calls in each style.
import { mkdirSync, writeFileSync } from "node:fs";

const [rpcs, calls] = process.argv.slice(2).map(Number);
const tags = Array.from({ length: rpcs }, (_, i) => `Rpc${i}`);
const config = (file) =>
  JSON.stringify({
    compilerOptions: { strict: true, target: "esnext", module: "nodenext", noEmit: true, allowImportingTsExtensions: true, skipLibCheck: true },
    files: [file],
  });

mkdirSync("src", { recursive: true });
writeFileSync("src/rpcs.ts", `import { type Layer, Schema } from "effect";
import { AtomRpc } from "effect/reactivity";
import { Rpc, type RpcClient, RpcGroup } from "effect/rpc";

class Failed extends Schema.TaggedError<Failed>()("Failed", { code: Schema.String }) {}

export const Rpcs = RpcGroup.make(
${tags.map((t) => `  Rpc.make("${t}", { payload: { id: Schema.String, n: Schema.Number }, success: Schema.Struct({ id: Schema.String, at: Schema.Number }), error: Failed }),`).join("\n")}
);

declare const protocol: Layer.Layer<RpcClient.Protocol>;
export class Client extends AtomRpc.Service<Client>()("Client", { group: Rpcs, protocol }) {}
`);

const styles = {
  none: () => `0`,
  flat: (t) => `Effect.gen(function* () { const client = yield* Client; return yield* client("${t}", { id: "a", n: 1 }); })`,
  methods: (t) => `Effect.gen(function* () { return yield* methods.${t}({ id: "a", n: 1 }); })`,
  query: (t) => `Client.query("${t}", { id: "a", n: 1 })`,
  mutation: (t) => `Client.mutation("${t}")`,
};
for (const [style, call] of Object.entries(styles)) {
  writeFileSync(`src/${style}.ts`, `import { Effect } from "effect";
import type { RpcClient, RpcClientError, RpcGroup } from "effect/rpc";
import { Client, type Rpcs } from "./rpcs.ts";

declare const methods: RpcClient.RpcClient<RpcGroup.Rpcs<typeof Rpcs>, RpcClientError.RpcClientError>;
export const used = [methods, Client] as const;
${Array.from({ length: calls }, (_, i) => `export const c${i} = ${call(tags[(i * 7) % rpcs])};`).join("\n")}
`);
  writeFileSync(`tsconfig.${style}.json`, config(`src/${style}.ts`));
}

bench.sh

#!/usr/bin/env bash
# bash bench.sh <rpcs> <calls>
set -e
node gen.mjs "$1" "$2"
printf "%-9s %15s %8s %10s\n" style instantiations check memory
for style in none flat methods query mutation; do
  out=$(npx tsgo -p "tsconfig.$style.json" --singleThreaded --extendedDiagnostics 2>&1) || { echo "$out" | head -5; exit 1; }
  printf "%-9s %15s %8s %10s\n" "$style" \
    "$(awk '/^Instantiations/{print $2}' <<<"$out")" \
    "$(awk '/^Check time/{print $3}' <<<"$out")" \
    "$(awk '/^Memory used/{print $3}' <<<"$out")"
done
What is the expected behavior?

A call should cost about the same whatever the size of its group, as the object-shaped client already does. Calling "Rpc5" shouldn't evaluate the other 599 RPCs.

What do you see instead?

With 600 RPCs and 300 calls (tsgo --singleThreaded --extendedDiagnostics, Effect 4.1.2):

style instantiations check time memory
none (group only) 421,316 0.65 s 194 MB
methods (RpcClient.RpcClient) 504,080 0.79 s 203 MB
flat (AtomRpc client) 17,663,261 13.2 s 999 MB
AtomRpc.query 11,843,988 11.5 s 984 MB
AtomRpc.mutation 6,768,871 7.3 s 971 MB

Here is how the flat style scales. The calls cycle through (i * 7) % N, so the number of distinct tags called is min(calls, N):

RPCs calls (distinct tags) flat instantiations
150 300 (150) 2,352,911
300 100 (100) 3,244,961
300 300 (300) 8,876,561
600 300 (300) 17,663,261

That's about 100 instantiations per (distinct tag × RPC) pair. Repeat calls to the same tag are cached, so the count of call sites doesn't matter. What matters is how many different RPCs the program calls.

In our app (about 590 RPCs and 355 call sites, through AtomRpc), this was about 34M of the program's 48.7M instantiations. It became a real problem with type-aware linting: oxlint's tsgolint keeps a checker per thread, so the cost is paid once per thread. The full type-aware lint peaked at 14.8 GB on 8 threads, and our CI runner (7 GB) was killed at 9.4 GB. Moving the calls to object-shaped methods took the program from 48.7M to 14.3M instantiations, tsgo from 5.4 GB to 3.5 GB, and the 8-thread lint from 14.8 GB to 7.8 GB.

Additional information

A proposed fix. Look the RPC up through a tag-keyed mapped type (built once per group), and let the conditionals distribute only over the RPC that was picked. A default type parameter keeps them distributive, so union tags (#8578) still give the same union result:

--- a/packages/effect/src/rpc/Rpc.ts
+++ b/packages/effect/src/rpc/Rpc.ts
@@ -696,15 +696,17 @@
  * @category utility types
  * @since 4.0.0
  */
-export type ExtractTag<R extends Any, Tag extends string> = R extends Rpc<
-  infer _Tag extends Tag,
-  infer _Payload,
-  infer _Success,
-  infer _Error,
-  infer _Middleware,
-  infer _Requires
-> ? R :
-  never
+export type ExtractTag<R extends Any, Tag extends string> = ByTag<R>[Tag & keyof ByTag<R>]
+
+/**
+ * Indexes RPCs by their tag. Computed once per group, so looking an RPC up by its
+ * tag doesn't test every RPC in the group.
+ *
+ * @stability unstable
+ * @category utility types
+ * @since 4.1.3
+ */
+export type ByTag<R extends Any> = { readonly [K in R as K["_tag"]]: K }
 
 /**
  * Extracts the services provided by middleware on the RPC with the specified
--- a/packages/effect/src/rpc/RpcClient.ts
+++ b/packages/effect/src/rpc/RpcClient.ts
@@ -170,7 +170,8 @@
   export type Flat<Rpcs extends Rpc.Any, E = never> = <
     const Tag extends Rpcs["_tag"],
     const AsQueue extends boolean = false,
-    const Discard = false
+    const Discard = false,
+    R = Rpc.ExtractTag<Rpcs, Tag>
   >(
     tag: Tag,
     payload: Rpc.PayloadConstructor<Rpc.ExtractTag<Rpcs, Tag>>,
@@ -194,8 +195,8 @@
          */
         readonly discard?: Discard | undefined
       }
-  ) => Rpcs extends Rpc.Rpc<
-    infer _Tag extends Tag,
+  ) => R extends Rpc.Rpc<
+    infer _Tag,
     infer _Payload,
     infer _Success,
     infer _Error,
--- a/packages/effect/src/reactivity/AtomRpc.ts
+++ b/packages/effect/src/reactivity/AtomRpc.ts
@@ -54,10 +54,10 @@
 
   readonly runtime: Atom.AtomRuntime<Self>
 
-  readonly mutation: <Tag extends Rpc.Tag<Rpcs>>(
+  readonly mutation: <Tag extends Rpc.Tag<Rpcs>, R = Rpc.ExtractTag<Rpcs, Tag>>(
     arg: Tag
-  ) => Rpcs extends Rpc.Rpc<
-    infer _Tag extends Tag,
+  ) => R extends Rpc.Rpc<
+    infer _Tag,
     infer _Payload,
     infer _Success,
     infer _Error,
@@ -66,7 +66,7 @@
   > ? [_Success] extends [RpcSchema.Stream<infer _A, infer _E>] ? never
     : Atom.AtomResultFn<
       {
-        readonly payload: Rpc.PayloadConstructor<Rpcs>
+        readonly payload: Rpc.PayloadConstructor<R>
         readonly reactivityKeys?: Reactivity.KeysInput | undefined
         readonly headers?: Headers.Input | undefined
       },
@@ -75,7 +75,7 @@
     >
     : never
 
-  readonly query: <Tag extends Rpc.Tag<Rpcs>>(
+  readonly query: <Tag extends Rpc.Tag<Rpcs>, R = Rpc.ExtractTag<Rpcs, Tag>>(
     tag: Tag,
     payload: Rpc.PayloadConstructor<Rpc.ExtractTag<Rpcs, Tag>>,
     options?: {
@@ -84,8 +84,8 @@
       readonly timeToLive?: Duration.Input | undefined
       readonly serializationKey?: string | undefined
     }
-  ) => Rpcs extends Rpc.Rpc<
-    infer _Tag extends Tag,
+  ) => R extends Rpc.Rpc<
+    infer _Tag,
     infer _Payload,
     infer _Success,
     infer _Error,

The same reproduction, with that change applied to the published .d.ts files of 4.1.2:

style (600 RPCs, 300 calls) instantiations check time memory
flat 17,663,261 → 8,302,979 13.2 s → 3.0 s 999 → 213 MB
AtomRpc.query 11,843,988 → 2,842,320 11.5 s → 1.6 s 984 → 197 MB
AtomRpc.mutation 6,768,871 → 1,357,827 7.3 s → 0.9 s 971 → 195 MB

What I checked on the patched 4.1.2 types:

  • Against verbatim copies of the current types, with an exact type-equality check, the results are identical for:
    • a plain call, discard: true, and a stream RPC, with and without asQueue: true
    • a union of two tags
    • query for a plain RPC, a stream and a union
    • mutation for a plain RPC, a stream and a union
  • A wrong payload and an unknown tag are still rejected.
  • With skipLibCheck: false, importing effect/rpc, effect/reactivity, effect/cluster and effect/workflow gives no errors, as before. A first version constrained the new parameter (R extends Rpc.Any = …) and failed there, hence the unconstrained default.

Caveats:

  • The extra defaulted type parameter shows up in signatures and hover text. An internal helper alias may be nicer.
  • The patched flat client is still about 16× the object client's instantiations at 600 RPCs. I didn't trace the rest; Tag & keyof ByTag<R> is one remaining per-call cost.
  • Separately, it would help if AtomRpc could hand out the object-shaped client in effects, the one RpcClient.make(group) gives without flatten. That would make client.Method(payload) available to AtomRpc users.

Our workaround in the meantime:

  • We build that object once from the flat client: one function per RPC in group.requests, each calling client(tag, payload, options).
  • We give query and mutation signatures that look the RPC up in a { [R in Rpcs as R["_tag"]]: R } map.
  • A type test checks both stay equal to AtomRpc's own types.
Lenguaje dominante
TypeScript
Estrellas
16.7k
Forks
808
Merge medio
11 h 31 min
PR fusionados (30 d)
834

Preparar el entorno

Este proyecto no incluye contenedor de desarrollo, Dockerfile ni guía de contribución, así que la configuración corre por tu cuenta: empieza por su README y consulta nuestra guía para la primera contribución para los pasos generales.

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de Effect-TS/effect

Todos los issues de Effect-TS/effect

Issues similares

Más issues de TypeScript

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.