← Back to Blog

Surgical tsconfig edits: injecting @pyrpc/types with jsonc-edit

·8 min read

Generated types live at <client>/__pyrpc.d.ts, but the client imports them as import type { Types } from "@pyrpc/types". Something has to make that import resolve to your file instead of the published package, and in pyRPC that something is a tsconfig paths alias injected automatically:

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

Getting that JSON into place without destroying the file is harder than it looks, which is why v0.11.0 ships a purpose-built module, pyrpc_core/tsconfig.py, backed by jsonc-edit.

Why not just read, parse, write?

The naive approach, json.load the tsconfig, mutate the dict, write it back with json.dump, destroys anything that is not strict JSON. Realtsconfig.json files routinely contain:

  • Comments, // ... and /* ... */ are legal JSONC but illegal JSON, and teams use them to explain paths entries and baseUrl decisions.
  • Trailing commas, every frontend toolchain happily accepts them; a round-trip through json.dump silently deletes them, producing noisy one-line diffs for unrelated fields.

Worse, a rewrite would reorder keys, re-indent the whole file, and touch lines the tool had no business touching. Editing a config file is a surgical operation: change exactly the bytes you mean to change, leave the rest untouched.

The SENTINEL trick

jsonc-edit’s modify() returns a list of edits, each with anoffset and length into the original source. To find out whether a path already exists without mutating anything, tsconfig.py asks for a write of a value that could never collide with real config, the string"SENTINEL", and inspects the edit it would make:

def _get_existing_value(source: str, path: list) -> str | None:
 edits = modify(source, path, "SENTINEL")
 if len(edits) == 1 and edits[0].content == '"SENTINEL"':
 return source[edits[0].offset : edits[0].offset + edits[0].length]
 return None

If modify produces exactly one edit whose content is the sentinel, then the path already exists and that edit is a replacement of the existing value, the span it covers is the current value, verbatim. If the path does not exist, modifyreturns either a different edit shape (an insertion) or an empty list, and the function returns None. It’s a read implemented with a write function, a neat trick that avoids maintaining a parallel JSONC parser.

Idempotency and conflict detection

Once we know the current value, the decision is straightforward. If the alias is already exactly ["./__pyrpc.d.ts"], there is nothing to do, a rerun is a no-op. Comparison strips whitespace and comments from the existing value so formatting differences don’t count as conflicts:

existing = _get_existing_value(content, ["compilerOptions", "paths", "@pyrpc/types"])
if existing is not None:
 no_comments = re.sub(r"//.*?\n|/\*.*?\*/", "", existing, flags=re.DOTALL)
 clean_val = re.sub(r"\s+", "", no_comments)
 if clean_val == '["./__pyrpc.d.ts"]':
 return True
 raise RuntimeError(
 f"@pyrpc/types is already configured to point elsewhere in {path}"
 )

If the alias points somewhere else, say a developer previously wired"./src/__pyrpc.d.ts" by hand, pyRPC does not silently override it. Silently repointing a developer’s explicit configuration would be a lie; instead the CLI raises, and the calling code turns it into a yellow warning naming the file that needs attention. This is the fail fast on ambiguity principle: an explicit user choice always wins over an automatic one.

Edge cases the tests pin down

test_tsconfig.py locks in the behavior the module must keep. The highlights:

  • Missing compilerOptions, the edit creates the whole branch and preserves an existing comment.
  • Missing paths, injects the mapping into existing compilerOptions without disturbing strict or include.
  • Existing paths with comments and trailing commas, keeps "~/*", the // some comment, and /* trailing comma above! */ all intact.
  • Already-correct alias, the file is returned byte-for-byte unchanged (idempotency).
  • Conflicting alias, raises RuntimeError instead of overwriting.
  • No tsconfig.json, returns True and creates nothing; the file may not exist yet.

That last case matters more than it looks: configure_tsconfig runs on every regeneration and every startup, so it must be safe against every intermediate state a half-configured project can be in.

Why the tool carries the contract

The alias is what makes the whole flow work: your frontend imports"@pyrpc/types", but TypeScript resolves it to ./__pyrpc.d.ts, so the published placeholder package is never even consulted. Keeping injection automatic & mdash; rather than a documented manual step, means the source-tree types feature is zero-config by construction, and rerunning pyrpc dev or pyrpc watch re-asserts the contract on every project you touch.

Read the full changelogfor the complete list of changes.