Skip to content

Instantly share code, notes, and snippets.

@schickling
Created December 28, 2025 12:26
Show Gist options
  • Select an option

  • Save schickling/cf887000bfe96b0116cd0cf53e0da98d to your computer and use it in GitHub Desktop.

Select an option

Save schickling/cf887000bfe96b0116cd0cf53e0da98d to your computer and use it in GitHub Desktop.
Effect RPC handler type-safety pitfall repro

RPC Handler Type Safety Pitfall Repro

Files

  • mod.ts — unsafe pattern (compiles, fails at runtime)
  • mod-fixed.ts — safe pattern (requirements surfaced, runtime succeeds)
  • EXPLANATION.md — detailed writeup with logs and illustration

Steps

  1. From repo root, run bun scratchpad/rpc-handler-type-safety-pitfall/mod.ts.
  2. Then run bun scratchpad/rpc-handler-type-safety-pitfall/mod-fixed.ts.

Expected

  • mod.ts: TypeScript accepts the _typeTest assignment, so the handler layer appears to need never. Runtime fails with a missing service error because MyService is not provided.
  • mod-fixed.ts: _typeTest requires MyService, and runtime succeeds when the layer is provided.

Why This Repro Matches The Pitfall

MyRpcs.toLayer(Effect.succeed(handlers)) erases the handler requirements, so the layer type does not surface MyService.

Effect RPC handler type-safety pitfall (repro)

Summary

When RpcGroup.toLayer is called with Effect.succeed(handlers), the handler effect’s requirements (R) are erased. This compiles even if a handler uses a service that is never provided, and it fails at runtime with “Service not found”.

This repro shows:

  • Unsafe pattern: compiles, but fails at runtime
  • Safe pattern: surfaces requirements in types and runs correctly

Repro layout

  • mod.ts — unsafe pattern
  • mod-fixed.ts — safe pattern

Steps

From the repo root:

bun scratchpad/rpc-handler-type-safety-pitfall/mod.ts
bun scratchpad/rpc-handler-type-safety-pitfall/mod-fixed.ts

Expected vs actual

1) Unsafe pattern (mod.ts)

TypeScript accepts this type test, suggesting no requirements:

type Handlers = Layer.Layer<Rpc.ToHandler<typeof MyRpcs>, never, never>

But at runtime, the handler uses MyService, which is never provided.

Runtime failure log (excerpt):

timestamp=2025-12-28T12:22:55.969Z level=ERROR fiber=#0 cause="Error: Service not found: @repro/MyService (defined at /.../mod.ts:4:55)"
error: Service not found: @repro/MyService (defined at /.../mod.ts:4:55)

2) Safe pattern (mod-fixed.ts)

The layer requirements are surfaced, and the program succeeds when the service is provided:

type Handlers = Layer.Layer<Rpc.ToHandler<typeof MyRpcs>, never, MyService>

Runtime success log:

timestamp=2025-12-28T12:22:58.755Z level=INFO fiber=#0 message=result message="worked: hello"

Why TS doesn’t catch it

Two pieces combine into a type hole:

  1. RpcGroup.of is an identity function

RpcGroup.of validates handler keys, but it doesn’t track handler return types or requirements. The handler object is returned as-is.

  1. Effect.succeed(handlers) erases requirements

Effect.succeed has type Effect<Handlers, never, never>, so the requirement channel R becomes never, even if handlers internally require services.

  1. HandlersContext inference can collapse to never

RpcGroup.toLayer tries to re-derive requirements from handler return types using nested conditional types. When the compiler can’t resolve those, it often falls back to never rather than producing an error.

Illustration

Handlers (uses MyService)
    |
    |  RpcGroup.of (identity)          Effect.succeed
    |  (validates keys only)           (R = never)
    v                                v
handlers object  ----------------->  Effect<handlers, never, never>
                                        |
                                        v
                                 RpcGroup.toLayer
                               (R often inferred as never)

Fix pattern

Lift service acquisition into the layer construction effect:

const MyHandlers = MyRpcs.toLayer(Effect.gen(function* () {
  const service = yield* MyService
  return MyRpcs.of({
    DoSomething: Effect.fn('rpc-do-something')(function* (input) {
      return yield* service.doWork(input)
    }),
  })
}))

This ensures MyService appears in the layer’s requirement channel so the compiler can catch missing providers.

import { Context, Effect, Layer, Schema } from '../../packages/effect/src/index.ts'
import { Rpc, RpcGroup, RpcTest } from '../../packages/rpc/src/index.ts'
class MyService extends Context.Tag('@repro/MyService')<MyService, {
readonly doWork: (input: string) => Effect.Effect<string>
}>() {}
const DoSomething = Rpc.make('DoSomething', {
payload: Schema.String,
success: Schema.String,
})
const MyRpcs = RpcGroup.make(DoSomething)
const MyHandlers = MyRpcs.toLayer(Effect.gen(function* () {
const service = yield* MyService
return MyRpcs.of({
DoSomething: Effect.fn('rpc-do-something')(function* (input) {
return yield* service.doWork(input)
}),
})
}))
const _typeTest: Layer.Layer<Rpc.ToHandler<typeof MyRpcs>, never, MyService> = MyHandlers
const program = Effect.gen(function* () {
const client = yield* RpcTest.makeClient(MyRpcs)
return yield* client.DoSomething('hello')
}).pipe(Effect.withSpan('rpc-test-program'))
const MyServiceLive = Layer.succeed(MyService, {
doWork: (input) => Effect.succeed(`worked: ${input}`),
})
const main = Effect.scoped(program).pipe(
Effect.provide(MyHandlers),
Effect.provide(MyServiceLive),
Effect.tap((result) => Effect.log('result', result)),
Effect.tapErrorCause((cause) => Effect.logError(cause)),
Effect.withSpan('rpc-test-main')
)
void Effect.runPromise(main)
import { Context, Effect, Layer, Schema } from '../../packages/effect/src/index.ts'
import { Rpc, RpcGroup, RpcTest } from '../../packages/rpc/src/index.ts'
class MyService extends Context.Tag('@repro/MyService')<MyService, {
readonly doWork: (input: string) => Effect.Effect<string>
}>() {}
const DoSomething = Rpc.make('DoSomething', {
payload: Schema.String,
success: Schema.String,
})
const MyRpcs = RpcGroup.make(DoSomething)
const handlers = MyRpcs.of({
DoSomething: Effect.fn('rpc-do-something')(function* (input) {
const service = yield* MyService
return yield* service.doWork(input)
}),
})
const MyHandlers = MyRpcs.toLayer(Effect.succeed(handlers))
const _typeTest: Layer.Layer<Rpc.ToHandler<typeof MyRpcs>, never, never> = MyHandlers
const program = Effect.gen(function* () {
const client = yield* RpcTest.makeClient(MyRpcs)
return yield* client.DoSomething('hello')
}).pipe(Effect.withSpan('rpc-test-program'))
const main = Effect.scoped(program).pipe(
Effect.provide(MyHandlers),
Effect.tap((result) => Effect.log('result', result)),
Effect.tapErrorCause((cause) => Effect.logError(cause)),
Effect.withSpan('rpc-test-main')
)
void Effect.runPromise(main)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment