← Back to Blog

Two channels: compile-time types and runtime kinds

·6 min read

The design that anchors everything in v0.12.0 can be stated in one sentence: type information and runtime capability are two separate channels, and codegen must emit both from a single source of truth. Everything else (the runtime module, the bundler aliases, the throwing placeholder) is a consequence.

The erasure problem

TypeScript erases types. A type { greet: (name: string) => Promise<string> } gives your editor autocomplete, but the running program has no idea that greet is a query rather than a mutation. The adapters must decide at runtime which hooks to expose. They cannot read a type.

// Channel 1, compile time. The compiler's view:
type Types = { greet: (name: string) => Promise<string> }

// Channel 2, runtime. The program's view:
const procedureKinds = { greet: "query" }

One channel is a description for the compiler. The other is a value for the runtime. They describe the same procedures and must never disagree.

Why a single generated module

The two channels could live in different files. But keeping them in one emitted module makes a strong guarantee: they are produced by the same template loop over the same schemas dict in the same pass. There is no third artifact that could drift. The satisfies ProcedureKinds constraint adds a compile-time proof at generation time: the runtime map is typechecked against the declared map before it ever ships.

The stable seam between them

The channels meet at @pyrpc/types. The compiler channel arrives as the Types interface; the runtime channel as the procedureKinds value. Both are named exports from the same package, which means the adapters import them through the same alias indirection. If the alias is misconfigured, both channels fail together, and the throwing placeholder makes that failure unmistakable.

Why this matters beyond pyRPC

Any codegen system that feeds a UI framework hits this split the moment its types influence behavior. The reusable lesson: do not smuggle runtime meaning into type-only constructs. If a type changes what your program does, emit a value for it, keep that value next to the type, and generate both from one schema. That discipline (not any single feature) is what makes the end-to-end guarantee real.

The payoff

Because the channels share one source, @rpc.mutation on the server is simultaneously: an autocomplete rule (type channel), a hook selection rule (runtime channel), and a request-semantics rule (the query keying). One decorator, three guarantees, zero coordination code.