pyRPC
← Back to Blog

How procedure kinds flow from Python to TypeScript

·12 min read

You write @rpc.mutation on a Python function. Three layers later, TypeScript autocomplete shows .useMutation() instead of .useQuery(). This post traces that journey end-to-end.

Step 1: the Python decorator

On the server, @rpc.mutation is a thin wrapper around @rpc. It sets kind=ProcedureKind.MUTATION on the procedure object:

from pyrpc_core import rpc

@rpc.mutation
def update_user(user_id: int, name: str) -> dict:
    ...

Under the hood, rpc.mutation calls the same register() as @rpc, but passes kind="mutation". The procedure stores it. No magic.

Step 2: introspection picks it up

When you run pyrpc codegen or hit GET /rpc, introspection serializes every procedure. Each one now includes a kind field:

{
  "procedures": {
    "get_user": { "params": [...], "result": "...", "kind": "query" },
    "update_user": { "params": [...], "result": "...", "kind": "mutation" }
  }
}

The default is "query". Bare @rpc works exactly like before — no breaking change.

Step 3: codegen emits two things

The Jinja2 template in client.ts.j2 reads the schema and produces:

  1. The Types interface (the same one you already know)
  2. A ProcedureKinds type and a procedureKinds const
export interface Types {
  get_user: (userId: number) => Promise<User>
  update_user: (userId: number, name: string) => Promise<User>
}

export type ProcedureKinds = {
  get_user: "query"
  update_user: "mutation"
}

export const procedureKinds = {
  get_user: "query",
  update_user: "mutation",
} as const satisfies ProcedureKinds

The type is for TypeScript. The const is for runtime — adapters need an actual value to compare against.

Step 4: the adapter reads kinds

You pass kinds: procedureKinds when creating the client. The adapter's Proxy handler checks the kind for each procedure name and returns the matching hook:

const api = createReactClient<Types>({
  baseUrl: "...",
  kinds: procedureKinds,
})

// get_user → "query" → useQuery
api.get_user.useQuery({ userId: 1 })

// update_user → "mutation" → useMutation
api.update_user.useMutation()

Without kinds, every procedure gets both hooks. That is the backward-compatible path for existing code.

Why this matters

The kind is declared once on the server. It cannot drift from the TypeScript side. If you add @rpc.mutation to a procedure, the next codegen run automatically flips the hook from useQuery to useMutation in your frontend. No manual wiring.

Procedures docs · Why procedure kinds exist