Every developer has pasted a JSON response into some “JSON to C#” page and copied the classes it gave back. A tool that types only the first object it sees works until the second item has a null where the first had a string, or an ID that no longer fits in an int. Then the generated class either throws at run time, far from where the JSON came in, or quietly claims that something can never be null.
I built json2types to do that job properly: paste JSON, get C# classes or records or TypeScript interfaces, as you type, entirely in the browser. This is how it works inside, and how I made sure the code it writes is right.
Parse the text, not just the values
The first decision was not to use JSON.parse. It turns every number into a JavaScript number, a 64-bit float, and by then information is gone:
9007199254740993comes back as9007199254740992, so the ID is silently off by one.10.0comes back as10, so a price written with a decimal point looks like an integer.
So json2types has its own small parser, written to RFC 8259. It keeps every number as the text it was written as, and every error says what went wrong and where, in the same words in every browser: Expected '}' or a comma after a property, found '"' (line 3, column 3). The page puts that message under the input, with a button that selects the spot. The parser also refuses nesting deeper than 500 levels, so a malicious paste can’t overflow the stack.
Merge every sample
The core idea is that a JSON document usually holds several examples of the same type: the items of an array, or the same object under different keys. The inferrer walks the document once and merges everything it sees at the same place into one shape, which records:
- every property seen, and in how many of the objects it appeared;
- how many values were
null; - for strings, whether every value was a date, a date-time or a GUID;
- for numbers, the smallest integer size that holds them all, whether any had a fraction or an exponent, and whether any fell outside what an exact decimal can hold.
Three samples of a customer show why that matters:
[
{ "id": 7, "email": "ana@x.pt", "since": "2026-10-18" },
{ "id": 3000000000, "email": null, "since": "2025-01-02" },
{ "id": 12, "since": "2024-06-30" }
]
Reading only the first item would give an int and a string, both wrong. Merging them, with the root type named Customer (the default is Root), gives this class, shown without the #nullable, using and comment lines above it:
public class Customer
{
[JsonPropertyName("id")]
public long Id { get; set; }
[JsonPropertyName("email")]
public string? Email { get; set; }
[JsonPropertyName("since")]
public DateOnly Since { get; set; }
}
and this interface, likewise without its comment line:
export interface Customer {
id: number;
email?: string | null;
since: string;
}
TypeScript can say what C# can’t: email is both optional (missing from the third item) and nullable (null in the second), and those are different things. C# folds both into string?, since a missing property reads as null there too.
Numbers are where the bugs are
Numbers get the most care, because a wrong numeric type fails only on the one value that doesn’t fit:
- Integers are sized from their text:
intup to 32 bits,longup to 64, then 128 bits and beyond. Past 64 bits C# gets the type each serializer can actually read:Int128for System.Text.Json,BigIntegerfor Newtonsoft.Json. I checked that both ways against .NET 10, because each one rejects the other’s type. Past 128 bits System.Text.Json has nothing exact left, so the property becomes adouble, with a comment saying it rounds. - Fractions become
decimalwhen every value is within a decimal’s range, anddoubleotherwise. The check works on the text, so it’s exact at the edges:79228162514264337593543950335.4still fits a decimal, but….5rounds past the maximum and doesn’t, and anything below 10⁻²⁸ would be rounded to 0 or to 10⁻²⁸. - Beyond ±2⁵³, a JavaScript
numbercan’t hold every integer, so the TypeScript gets a comment on that property:JSON.parsemay lose digits there.
Dates and GUIDs have to earn their type
A string becomes a DateTimeOffset, DateTime, DateOnly or Guid only when every value at that place qualifies, and only in the strict ISO 8601 form System.Text.Json reads. One odd value and the property stays a string. A tool that guesses from the first value turns "2026-10-18" into a date and then breaks on "tomorrow".
Offsets decide between the two date-time types. If every value has one (Z or +01:00), it’s a DateTimeOffset. If none does, it’s a DateTime. When they’re mixed, it’s a DateTimeOffset, with a note under the code saying so.
Advertisement
Names every compiler accepts
JSON keys can be anything. Types and C# properties need identifiers, and they turned out to be the fiddliest part:
- Keys become PascalCase (
first_namegivesFirstName), and the items of an array get a singular name (ordersgivesOrder). - A type that would shadow a common built-in type gets a suffix: a key named
stringgives the typeStringItem, not a class calledString. - C# forbids a member named like its own class, so in
{"user": {"user": "ana"}}the property becomesUserValue, still mapped to"user"by its attribute. Keys that differ only by case are numbered:aandAbecomeAandA2. - Letters must be ones every compiler knows. The browser’s Unicode tables are newer than those of many compilers, so a key with a recently added letter produced C# that failed with CS1056 (C# also refuses any letter outside the Basic Multilingual Plane, whatever its age). Names now use only long-established Unicode blocks: accented Latin, Vietnamese, Greek, the core Cyrillic, Hebrew and Arabic letters, Thai, Japanese kana, the core Chinese characters and Korean. Letters outside them separate words instead.
TypeScript keeps the JSON’s own property names and quotes the ones that aren’t plain ASCII identifiers ("first-name": string). Anything invisible inside a quoted name, such as a bidirectional override or a zero-width joiner, is written as an escape, so the source can’t read differently from what it does.
Sharing types
Objects found under the same key with the same properties of the same types become one type. billing.address and shipping.address share an Address. A user and a product that both happen to be {"id": …} don’t, because their keys differ.
Tested against the real compilers
Unit tests prove the generator writes what I meant; they don’t prove the code compiles. So the generated code is checked by the compilers themselves:
- C#: a test compiles the output for 15 sample documents, in every combination of the options, with warnings as errors, both with the
dotnet newdefaults and with nullable reference types and implicit usings off. It then reads each sample back through the generated types with System.Text.Json and Newtonsoft.Json, writes it out again, and, wherever the names let it read every property, compares the values: numbers exactly, dates down to the offset. It runs in the São Paulo time zone, so values read as local time are told apart from UTC ones. - TypeScript: the same samples are type-checked in memory with the TypeScript compiler in strict mode, each one assigned as a literal to its root type, under every option. Wrong samples are checked too: each must fail on the sample line with the expected error code, so the test can’t pass by accident.
They pay for themselves: the C# compile run is what caught the CS1056 names above, on a sample I’d added for TypeScript.
Private by construction
The page promises that nothing pasted into it leaves the browser, and its Content Security Policy backs that up: connect-src 'none' blocks every request a script can make with fetch, XHR or a WebSocket, nothing loads from other origins, and, in browsers that support them, Trusted Types make it impossible to write the generated code into the page as HTML.
The conversion runs in a Web Worker, so a 1 MB paste converts in about half a second while the page stays responsive. That worker needed a trick. Loaded from a file of its own, a worker takes its security policy from that file’s HTTP headers, and GitHub Pages sends none, so it could have reached the network while the page couldn’t. The worker is instead bundled into the page’s script as text and started from a blob: URL, which inherits the page’s policy. A test checks that a fetch from inside the worker fails.
Checked on every change
Every pull request runs the unit tests, the .NET round trip, end-to-end tests in Chromium against the built site, axe accessibility checks on every page in light and dark mode on desktop and phone, and Lighthouse. Each page has to score at least 95 in performance, accessibility, best practices and SEO; today they all score 100.
json2types is at json2types.jonathas.net. Paste a response you work with, and if it writes something wrong, I’d like to know.


