← Back to Blog

Inside the codegen template

·9 min read

Codegen is a small engine with three moving parts: a JSON-Schema collector, a type-converter, and a Jinja2 template. The template (client.ts.j2) is where the Python schema dict becomes the TypeScript module. This post walks it top to bottom.

The data: a schema dict

The engine receives schemas, a dict of procedure name to schema object. Each schema carries parameters, a return_type, a kind, and a doc string. The template renders once per procedure.

The Types interface

export type Types = {
  {% for name, schema in schemas.items() %}
  /**
   * {{ schema.doc or "No documentation available." }}
   * @kind {{ schema.kind | default("query") }}
   */
  {{ name }}: (({% for param in schema.parameters %}...{% endfor %}) => Promise<...>) & {
    readonly _pyrpcKind: "{{ schema.kind | default("query") }}";
  };
  {% endfor %}
}

Three details hide in that block. First, schema.doc or "No documentation available.", the Python docstring is lifted into a JSDoc comment, so hover text in your editor mirrors the server-side documentation. Second, @kind records the procedure kind in the comment as a human-readable copy. Third, the intersection with readonly _pyrpcKind brands the type so it can be recovered at the type level later.

Type conversion happens in filters

The template does not compute types. It delegates to two Jinja filters registered in generate_typescript_client:

env.filters["pytype_to_ts"] = _pytype_to_ts
env.filters["return_type_to_ts"] = _return_type_to_ts

Keeping conversion in filters, rather than precomputing strings in Python, means the template stays declarative: it says where a type goes, the filter decides how to render it. The default("query") filter chained in the same spot is the safety net for procedures registered with bare @rpc, which carry no explicit kind.

The runtime map

export type ProcedureKinds = {
  {% for name, schema in schemas.items() %}
  {{ name }}: "{{ schema.kind | default("query") }}";
  {% endfor %}
};

export const procedureKinds = {
  {% for name, schema in schemas.items() %}
  {{ name }}: "{{ schema.kind | default("query") }}",
  {% endfor %}
} as const satisfies ProcedureKinds;

The const uses as const to freeze literal types and satisfies ProcedureKinds to prove the value conforms to the declared shape. The @internal doc tag marks both as implementation detail, not public API.

Assembly

The template output is one half of the file. The other half (the model interfaces for complex schemas) comes from jsonschema_ts. assemble(models=..., procedures=..., banner="") concatenates them. The template is deliberately procedural-only: model extraction is delegated to the library, procedure wiring stays hand-rolled, and the two meet in one artifact.

Why a template at all

String concatenation in Python would produce the same bytes but none of the readability. A template keeps the output's shape visible, you can read the TypeScript structure without mentally executing Python. That matters because the output is the API surface developers stare at most. The template is the spec, and the spec is meant to be read.