← Back to Blog

The _pyrpcKind brand and type-level kind inference

·7 min read

The runtime map procedureKinds tells the running adapter which hooks to expose. But your editor needs the same information before anything runs, it has to know that update_user has no useQuery. That is the job of a type-level brand.

The brand, planted by codegen

Every generated procedure type is intersected with a branded object literal:

greet: ((name: string) => Promise<string>) & {
  readonly _pyrpcKind: "query";
};

The _pyrpcKind property carries the procedure's kind as a literal type. The readonly modifier stops accidental reassignment, and the whole intersection means the brand travels alongside the callable signature. This is structural branding: the kind is a real property of the type, not a side table.

Reading the brand back out

The adapter maps over TProcedures and, for each key, inspects the brand:

export type InferProcedureKinds<TProcedures extends ProceduresRecord> = {
  [K in keyof TProcedures]: TProcedures[K] extends {
    readonly _pyrpcKind: infer Kind;
  }
    ? Kind extends ProcedureKind
      ? Kind
      : undefined
    : undefined;
};

For each procedure key it does a conditional-type match: does the procedure type have a _pyrpcKind? If so, is the inferred Kind a valid "query" | "mutation"? The result is a map like { greet: "query"; update_user: "mutation" }, the compile-time twin of the runtime const.

The undefined branch is the safety net

The fallback branches yield undefined. Why not "query"? Because a procedure whose kind cannot be proven should not be silently assumed safe, undefined flows into ProcedureHooksForKind and resolves to "both hooks" rather than the wrong one. It mirrors the runtime philosophy: when in doubt, expose more rather than guess.

Where inference plugs in

createReactClient<TProcedures> returns ReactClient<TProcedures, InferProcedureKinds<TProcedures>>. The inferred kinds map selects, per procedure, one of three hook bundles:

  • "mutation" → only useMutation
  • "query" → only useQuery
  • undefined → both

So api.greet.useMutation is a compile error while api.update_user.useMutation typechecks, the type system has already read the server's decorators.

Why branding beats a separate kind union

A parallel ProcedureKinds map already exists as a type. But deriving the hooks from the branded Types interface means there is exactly one source of truth: the procedure type itself. If codegen ever changes a kind, the hooks signature changes with it automatically, and no second structure can drift out of sync.