← Back to Blog

Types in your source tree: why generated types left node_modules

·8 min read

pyRPC generates a TypeScript declaration file describing your Python procedures. Where that file lives is a product decision, not an implementation detail. For a long time it lived innode_modules/@pyrpc/types/src/index.ts, like the type package it replaced. The source-tree model moves it to <client>/__pyrpc.d.ts, a file inside your frontend, tracked in git alongside everything else.

The node_modules problem

Writing generated types into node_modules worked, but it was structurally wrong in three ways:

  • Non-durable. node_modules is disposable by contract. Any npm install, any CI checkout that skips it, any npm ci run, and your types are gone, regenerated from a cold start or, worse, silently stale.
  • Not reviewable. You cannot diff a generated file you never committed. Changes to procedure signatures flew past without showing up in pull requests.
  • Confusing ownership. A file that belongs to a dependency directory reads as someone else’s code, even though pyRPC wrote it. Debuggers, linters, and teammates all misattribute it.

The source-tree contract

The new model treats generated types like any other source file. There is one convention and it is enforced in one place:

_DEFAULT_CLIENT = "."

# ...in _regenerate_clients:
output_path = os.path.abspath(os.path.join(client_dir, "__pyrpc.d.ts"))

Types land at <client>/__pyrpc.d.ts, relative to the client root configured in pyrpc.json. The .d.ts extension means TypeScript treats it as a declaration file; the leading double-underscore keeps it visually separate from your own modules and immune to tsc glob collisions with your src tree.

The file’s header makes the contract explicit:

/**
 * Auto-generated by pyrpc dev, do not edit by hand.
 * Re-generated automatically whenever your Python procedures change.
 *
 * Resolved via tsconfig.json paths alias:
 * "paths": { "@pyrpc/types": ["./__pyrpc.d.ts"] }
 */

Committed, diffed, trusted

Because the file is committed, it becomes part of your review loop:

  • Renames show up. Rename get_user to fetch_user on the server and the next regeneration produces a visible diff in __pyrpc.d.ts, the frontend change is now an explicit review point.
  • CI is deterministic. A fresh checkout has your types from git immediately; the watcher only needs to run to update them.
  • No reinstall races. Nothing about the workflow depends on npm having run, or on postinstall scripts having fired.

How imports still resolve

Your frontend code never imports ./__pyrpc.d.ts directly. It writes the stable import, and the tsconfig alias redirects it:

import type { Types } from "@pyrpc/types"

// tsconfig.json
"compilerOptions": {
 "paths": {
 "@pyrpc/types": ["./__pyrpc.d.ts"]
 }
}

The @pyrpc/types package still exists on npm, it ships a placeholder and the ProcedureKinds constant used by the framework adapters. But for yourprocedures, TypeScript resolves the alias to the generated file in your tree, so the published package’s placeholder is never what you actually type-check against.

What you gain

The source-tree model is the quiet foundation of everything else: multi-client support works because each client simply owns its own committed __pyrpc.d.ts; the tsconfig injection works because there is a fixed, predictable path to alias; CI works because nothing depends on ephemeral install state. It is the difference between generated code you tolerate and generated code you treat as part of your codebase, which is exactly how the best typed-contract tools treat their output.

Read the full changelogfor the complete list of changes.