Somewhere between a Python annotation like dict[str, list[int | None]] and a TypeScript signature like Record<string, (number | null)[]> sits a string parser. It is 120 lines, recursive, and it has to get the grammar right without ever seeing an AST.
The type map
The base cases are a lookup table. Python runtime types arrive as their repr, e.g. <class 'int'>, and introspection stores them as strings:
_TYPE_MAP = {
"int": "number", "float": "number", "str": "string",
"bool": "boolean", "None": "null", "NoneType": "null", "Any": "any",
}A <class '...'> repr is unwrapped, its dotted prefix stripped, and the leaf name looked up. Unknown classes fall through to _to_safe_name, which transliterates any Unicode name to ASCII and PascalCases it, a User model becomes User.
The recursive grammar
Everything non-trivial is a prefix match on typing.-stripped strings:
Optional[X]→X | nullUnion[...]→ a pipe union, with null collapsingList[X]/list[X]→X[]Dict[K, V]/dict[K, V]→Record<K, V>Tuple[...]→ a TS tuple[A, B]Set[X]→Set<X>
The recursion is what makes it composable: list[dict[str, Optional[int]]] walks down through four levels because each rule calls _pytype_to_ts on its inner content.
The depth-aware splitter
Splitting a union by comma naively would shred nested generics: Union[dict[str, int], None] has a comma inside the dict. _split_type_args walks the string tracking bracket depth and only cuts commas at depth zero:
for c in s:
if c in "[(": depth += 1
elif c in "])": depth -= 1
elif c == "," and depth == 0: parts.append(current); current = ""
else: current += cThe same trick that makes the parser correct is also its ceiling: it understands nesting, not types. It will split any balanced bracket structure, but it will not typecheck the contents.
Null collapsing
Python's Optional[X] and Union[X, None] are the same thing. The Union branch normalizes them: it renders the non-null members joined by pipes and tacks | null onto the end. That produces the idiomatic string | number | null instead of the awkward null | string | number.
The escape hatch
Everything unparseable returns any. That is a deliberate tradeoff: failing hard on an exotic annotation would make codegen unusable for the long tail of typing idioms, so the parser degrades to untyped and lets the developer refine by hand. The cost (losing type safety on that one procedure) is visible in the generated file, which keeps it honest.
Why strings and not an AST
The annotation strings come from runtime introspection, not from parsing source. Holding an AST would mean either executing typing.get_type_hints and reflecting the resulting objects, or walking the module's AST and losing forward references. The string form is the stable, portable currency between introspection and codegen, and a 120-line recursive parser is a small price for not coupling to a specific Python version's typing internals.

pyRPC