Notes › Tech Notes › When JSON Becomes Code for 7 Languages, 8 Prototype-Chain Key Names Erase a Whole Field and null Fields Still Count as Required

When JSON Becomes Code for 7 Languages, 8 Prototype-Chain Key Names Erase a Whole Field and null Fields Still Count as Required

I was adding type validation to a CI pipeline when something odd surfaced: one JSON sample turned into typed code for seven languages, and the TypeScript interface quietly lost a key on its own. The whole generation pass emitted exactly one note, which read "duplicate key name in the same object, keeping the last occurrence," yet those two keys were nothing alike.

I traced the generator behind JSON to code all the way back, replayed the same batch of samples level by level, and found this is not the only place where the output disagrees with the input.

Eight key names make a whole field vanish

The duplicate check on line 577 uses node.vals[k.v] !== undefined, which walks the prototype chain. Members hanging off Object.prototype get mistaken for "already seen" keys and dropped. Eight key names get swallowed in total: constructor, __proto__, toString, valueOf, hasOwnProperty, isPrototypeOf, propertyIsEnumerable, toLocaleString.

Input sampleTypeScript bodyNotes
{"toString":1,"keep":2}only keep: number survives1 duplicate-key note
{"toString":1}an empty Root interface2 notes
{"foo":1,"keep":2}both fields present0 notes

When not a single field is left, types still records 1, and the note only carries a key path shaped like $.a; it never says which occurrence, so there is no position to look back at.

Null fields are not uniformly marked nullable

The nullable marker only appears when null and a non-null value show up at the same position. [{"a":null},{"a":1}] yields the TypeScript union number | null, C# int?, Go *int, Python Optional[int], and Dart int?; a lone {"a":null} yields TypeScript a: any, Go interface{}, and Python Any, and across all seven targets it produces 0 notes. In the first-screen sample, both the note field and address.zip fall into required, and Address carries the required pair ["city", "zip"].

Big integers lose their last digit during parsing

{"a":9007199254740993} parses to 9007199254740992; Java and C# hand back long, Go hands back int64, and TypeScript stays number with 1 extra note reading "integer exceeds the safe double range." The test on line 668 is a strict greater-than against 9007199254740991, so a sample sitting exactly on the boundary gets the same type yet 0 warnings, and the generated code never carries a numeric literal anywhere.

Numeric typing looks at how a value is written, not how big it is: 2147483647 goes to int everywhere, while 2147483648 and negative 2147483649 only get promoted to long or int64 (line 913). The spellings 1e2 and 1.0 get classed as float because of the e or the decimal point, yet 100 with the identical value still comes out as int. 1e400 raises no error: parseFloat returns Infinity, it only passes the isNaN check on line 553, and the output stays double and float64 with 0 warnings.

Key names get rewritten, but Chinese keys are left alone

The cleaning chain runs through jlIdentFor on line 438 to 462: hello world becomes helloWorld in TypeScript, HelloWorld in C# and Go, and hello_world in Python. For Chinese keys the gatekeeper is jlIsIdent on line 415, which lets any charCode greater than 127 start an identifier, so "用户名" stays verbatim across TypeScript, Java, C#, Python, and Dart, while only Go prefixes it into "X用户名"; the Schema target does not touch it at all.

The keep-original-key-name switch only exists on the TypeScript target (line 152 to 158, the lone control among 19 that carries a key-name policy). Everywhere else the original name lands somewhere different: Java annotates with @JsonProperty and adds the import on line 1236, C# writes [JsonPropertyName("hello world")], Python only emits Field(alias=...) in pydantic mode, and a dataclass that does not tick the emit-comments box simply has no original name at all (line 1401).

Split-out class names carry no parent prefix

A six-level nesting does not glue the root type name onto the children: counting from the innermost class outward the names run L5 through L1 and then Root, and types records 6.

lines per target TypeScript 24 Go 26 Python 32 C# 42 Schema 47 Java 80 Dart 108

Fig. 1: line counts on each of the seven targets for the six-level nesting sample.

Array element class names run through jlSingularize on line 471 to 484, so Categories becomes Category while Bysis stays put; when two elements share no key, the unioned fields all come out optional and each carries 1 note. Empty arrays and empty objects degrade into an any type, TypeScript picking up any[], with warnings defined on line 682 and 700.

Every warning passes two ceilings

The collection ceiling is JL_WARN_CAP=400 on line 237, and once line 644 has enough it just does return and silently discards the rest; the display ceiling is JL_MAX_WARNINGS=40 (line 235 and 1887), so the interface only draws the first 40 entries.

warning count cap 60 elements 60 items 420 elements 400 items 40 drawn hidden

Fig. 2: total warnings against what you can see, the green segment being the 40 shown.

An alternating, mixed-type array of 60 elements raises warnings=60, and the interface shows 40 plus "20 more warnings not shown."; with 420 elements the warnings array is first cut down to 400 (lines=427, types=2), and the tail reads "360 more warnings not shown."

The three left-side counters are not byte counts

The three columns above come from line 1878 to 1880: text.length counts characters, the split counts lines, and the size figure is jlHumanSize(text.length), while JL_BIG_INPUT=2097152 on line 236 also compares character counts. A Chinese-key sample of 59 characters is really 93 bytes, the interface shows a size of 59 B, which is 34 short of the byte count; read by character count the 2 MB ceiling does not actually hold. Format and Compress (line 1976 to 1977) run JSON.parse then JSON.stringify, keep none of the original text, and turn 1e400 straight into null.

Common questions

Q: Can the C# target change what shape its types take?

It cannot; it only emits public class. In a sample where types=4 all four classes come out as plain classes, and the single control in the C# group is the emit-using-file-header toggle (line 171 to 172); a full-text search of the template finds no record branch at all.

Q: If I pick exact-decimal on a money field, am I safe?

It only truly applies to 3 targets: Java emits BigDecimal, C# emits decimal, and Python emits Decimal; TypeScript stays number, Dart stays double, and Schema stays integer, while Go adds a 1 note from line 1690 because the standard library has no exact-decimal type.

Q: Can I merge several responses together and infer from all of them?

You can, and it is the only path that surfaces optional and nullable fields at all: the decision on line 623 accepts a single JSON value, so multiple samples have to be assembled into an array by yourself before you feed them in.

That vanished field is not an inference defect; the key name just collided with one member on the prototype chain. I treat the output as a draft rather than a contract, walk field names, nullability, and numeric types by hand, and only let the TypeScript version straight into the repo; for the rest I first re-check names against the generator-to-source correspondence for JSON to code.

📎 Related articles

Same 70 Bytes, 13 MIME Types: file-info Reads the Extension, Never the Content 2026-10-23 My Transparent Logo Came Back as a White JPG, and 91 Quality Levels Changed Nothing on the PNG Side 2026-10-23 Remove Everything Still Left 553 Bytes, and Location Only Never Touched the Thumbnail 2026-10-23 Pad Width Is Only a Floor: Where (2) Lands When a Batch Rename Hits 2000 Files 2026-10-23 Two Certificate Fingerprints, 95 Characters and 64: The Missing 31 Are All Colons 2026-10-23