Flat RPC client and AtomRpc query/mutation: type-check cost grows with RPCs in the group × tags called
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 anAtomRpc.Servicequery:Client.query("RpcK", payload)mutation:Client.mutation("RpcK")methods: the object-shapedRpcClient.RpcClient, asclient.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 withoutasQueue: true - a union of two tags
queryfor a plain RPC, a stream and a unionmutationfor a plain RPC, a stream and a union
- a plain call,
- A wrong payload and an unknown tag are still rejected.
- With
skipLibCheck: false, importingeffect/rpc,effect/reactivity,effect/clusterandeffect/workflowgives 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
AtomRpccould hand out the object-shaped client in effects, the oneRpcClient.make(group)gives withoutflatten. That would makeclient.Method(payload)available toAtomRpcusers.
Our workaround in the meantime:
- We build that object once from the flat client: one function per RPC in
group.requests, each callingclient(tag, payload, options). - We give
queryandmutationsignatures 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
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de Effect-TS/effect
-
Xml: `positions` decode option that puts `line` and `column` on `StartElement` eventsPosiblemente ocupada @effect-bot la tomó hoy. Abierto
Effect-TS/effect#10400 · 1 asignado ·
Los mantenedores suelen responder en 1 día
-
effect/ai: support the MCP Apps extension (`io.modelcontextprotocol/ui`)Posiblemente ocupada @effect-bot la tomó hoy. Abierto
Effect-TS/effect#10399 · 2 comentarios · 1 reacción · 1 asignado ·
Los mantenedores suelen responder en 1 día
-
Support `format: "password"` as `Redacted` in JSON Schema import/export and the OpenAPI generatorPosiblemente ocupada @effect-bot la tomó hoy. Abiertoenhancement
Effect-TS/effect#10394 · 2 comentarios · 1 asignado ·
Los mantenedores suelen responder en 1 día
-
Versioned schemas with version negotiation in HttpApi, Rpc and clusterPosiblemente ocupada @effect-bot la tomó hace 1 día. Abierto
Effect-TS/effect#10383 · 2 comentarios · 1 asignado ·
Los mantenedores suelen responder en 1 día
-
Schema encoding can return an internal symbol across module copiesPosiblemente ocupada @schickling-assistant la tomó hace 1 día. Abierto
Dificultad 4/5 3-5 días Aptitud para principiantes 25/100
Los mantenedores suelen responder en 1 día
Todos los issues de Effect-TS/effect
Issues similares
-
unwrapDatabaseError masks real DB errors with TypeError: Cannot convert object to primitive valueAbierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 74/100
NEARBuilders/everything-dev#798 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
vercel-labs/durable-harness#53 ·
Los mantenedores suelen responder en 1 día
-
[Bug]: Redesign tab bars ignore clicks on Linux: hidden chat layers keep window drag regionsPosiblemente ocupada Un pull request vinculado a esta issue está abierto o ya se fusionó. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 82/100
Los mantenedores suelen responder en 1 día
-
experiência: a doc manda subir para o Micro do Supabase, mas não diz que exige plano pago nem quanto custaPosiblemente ocupada Un pull request vinculado a esta issue está abierto o ya se fusionó. Abiertoarea/docs area/ui enhancement
Dificultad 1/5 1-3 horas Aptitud para principiantes 88/100
melgarafael/DeskcommCRM#4098 ·
Los mantenedores suelen responder en 1 día
-
dependencies platform: ios
Dificultad 1/5 1-3 horas Aptitud para principiantes 78/100
software-mansion/react-native-executorch#1617 ·
Los mantenedores suelen responder en 1 día