Turning a response body into a C# model file
A JSON document carries six types. C# carries a type system deep enough to describe all of them, plus the distinctions JSON has no way to express: whether a number is money, whether a string is an identifier, whether an absent key means null or means nothing at all. Writing the model by hand is where those distinctions go wrong, usually on the one field somebody forgot could arrive empty. Generating from real data settles most of it in a single pass, and the three output styles above match the three reasons a .NET project wants a model class in the first place.
Class, record, or init-only
The three buttons produce genuinely different code, not three coats of paint on the same properties. Pick by how the object gets built and whether anything is allowed to change it afterwards.
| Style | Mutability | Minimum C# | Reach for it when |
|---|---|---|---|
| Class | Fully mutable, get; set; | C# 8 | The model is bound by ASP.NET Core, mapped by EF Core, or edited after deserialization |
| Record | Immutable positional parameters | C# 9 | The payload is a message you read once, compare, and pass along without touching |
| Init-only | Settable at construction, frozen after | C# 11 for required | You want object initializer syntax and a compiler error when a field is left out |
The record style writes a positional record, so Equals, GetHashCode, deconstruction and a readable ToString all arrive free. That value equality is the real reason to pick it: two ticket objects with matching fields compare equal, which is what you want when diffing two responses or caching by content. The trade is that a positional record has no parameterless constructor, so anything doing property-by-property binding rejects it.
The init-only style marks every non-nullable property required. Miss one in an object initializer and the build fails rather than the field quietly sitting at null. That needs C# 11, so on an older target framework switch to the plain class style instead.
Which serializer the attributes target
C# property names are PascalCase. JSON keys usually are not. Something has to carry that mapping, and the two libraries spell it differently.
[JsonPropertyName("minutes-to-first-reply")]public double MinutesToFirstReply { get; set; }[JsonProperty("minutes-to-first-reply")]public double MinutesToFirstReply { get; set; }The attribute is written whenever the C# name is not a byte for byte match with the JSON key, casing included. That looks redundant on a key like subject mapping to Subject, and it is deliberate. JsonSerializer.Deserialize called directly is case sensitive by default. ASP.NET Core flips PropertyNameCaseInsensitive on in its own options, so the same model works there without attributes and breaks the moment you call the serializer yourself in a console app or a background worker. Writing the attribute makes the model behave the same in both places.
Switching the dropdown to No attributes gives you a clean file with nothing but properties. Use it when your serializer options already carry a naming policy, such as JsonNamingPolicy.CamelCase, and you would rather keep that single rule in one place than repeat it on every property.
Records need a different attribute spelling. On a positional record the parameter is not the property, so a bare attribute lands on the constructor parameter and the serializer never sees it. The output uses [property: JsonPropertyName("...")] to push the attribute onto the generated property. Copying an attribute off a class into a record without that prefix is one of the quieter ways to end up with every field null after a deserialize that threw no error.
How each JSON value becomes a C# type
Inference reads values, not keys. Every sample of a field contributes, so the type widens as more of the payload is read.
| What the JSON holds | C# type written | Reasoning |
|---|---|---|
"text" | string | Default for any string with no recognised format |
"2026-08-14T09:21:05Z" | DateTimeOffset | Only when every sample of the field parses as a timestamp |
"2026-08-14" | DateOnly | Date with no time part, which needs .NET 6 or newer |
"8f2b1c4e-0d3a-..." | Guid | Canonical 8-4-4-4-12 hex form |
2 | int | Whole numbers inside the 32 bit range |
9007199254740000 | long | Past int.MaxValue, so the whole field widens |
18.5 or a mix | double | One decimal sample widens the field, even if the rest are whole |
true | bool | Read before number, since JSON keeps them separate |
null alongside values | T? | Null is folded into the type rather than replacing it |
null and nothing else | object? | No sample ever showed the real type |
[] | List<object> | An empty array carries no element type |
{ ... } | A generated type | Named after the key that held it, singularised inside arrays |
Turning off Read dates and GUIDs keeps every string as string. Do that when your sample is small. A field where nine values parse as timestamps and the tenth is the word pending would be typed DateTimeOffset from the first nine, then throw a JsonException on the tenth in production.
Nullable reference types change what the output has to say
With <Nullable>enable</Nullable> in your project file, the compiler treats string as never null and warns on any property it cannot prove gets assigned. That is CS8618, and a generated model with twenty string properties produces twenty of them. The class style silences it the way most hand written models do.
public string Subject { get; set; } = null!;public List<string> Tags { get; set; } = new();public string? ResolvedBy { get; set; }Read those three lines carefully, because they say three different things. Subject promises the compiler a value it does not actually have yet, which is a promise the deserializer keeps and a manually constructed object does not. Tags gets a real empty list, so iterating a payload that omitted the key gives you zero items rather than a NullReferenceException. ResolvedBy is honestly nullable, so every call site is forced to check.
Clear the checkbox and the annotations disappear on reference types, leaving plain string throughout. Value types keep their question mark either way, since int? is a different type rather than a compiler annotation.
What an array of objects tells the generator
This is the part worth understanding, and the reason to paste a whole response rather than one trimmed record. Every element of an array is folded into a single shape. A key present in all of them stays required. A key present in some of them comes through nullable.
"replies": [{ "author": "Priya Raghunathan","body": "Certificate expired." },{ "author": "Marcus Adeyemi","body": "Reissued.","internal": true }]public class Reply
{public string Author { get; set; } = null!;public string Body { get; set; } = null!;public bool? Internal { get; set; }}Three decisions landed there. The array key replies was singularised, so the type is Reply and the property is List<Reply>. The internal key appeared in one entry out of two, so it came through as bool? rather than bool. And the notes panel under the editors says so, in the words "missing from 1 of 2 records", which is the signal that your sample is doing real work.
Two objects with matching field names, matching types and matching optionality get one type between them. In the sample payload, requester and assignee both hold a name, an email and a flag, so both properties are typed Requester and no Assignee class is written. On a large payload this is the difference between six types and sixty. When the two are separate concepts in your domain, copy the class after generation and point one property at each.
Key names C# will not take
JSON keys are arbitrary strings. C# identifiers are not, and four collisions turn up constantly in real payloads.
- Punctuation.
minutes-to-first-replyanduser.namebecomeMinutesToFirstReplyandUserName. The attribute preserves the wire name, so the rename costs you nothing at deserialize time. - Leading digits. A key such as
2fagets anNprefix, since no C# identifier starts with a number. - A key matching its own type. C# forbids a member named after the class holding it, so a
childkey inside aChildtype becomesChildValue. This one is easy to miss by hand and produces error CS0542. - Names that collide after conversion. A payload carrying
userId,user_idanduser-idgives one PascalCase name three times over. The second and third get numeric suffixes, and the notes panel flags it, because at that point the payload itself deserves a look.
Reserved words are handled too. A key literally named class becomes the property Class, which is legal, since C# keywords are lowercase. The at sign prefix only appears in the rare case where PascalCase still lands on a keyword.
Where the guess is wrong
Type inference is a reading of the data in front of it, not a schema. Five failures are worth knowing before the file goes into a repository.
- Money typed as double. A price of
18.5is a decimal number to any JSON parser, anddoubleis what comes out. Money belongs indecimal, and no amount of sampling reveals that a field is money. Change it by hand, every time. - DateTimeOffset where you wanted DateTime. The output prefers
DateTimeOffsetbecause it keeps the zone offset the payload carried. If your storage layer expectsDateTime, swap it, knowing you are dropping information the JSON gave you. - A nullable field that happened to be filled. If
resolvedAtheld a timestamp in your one sample, it is typed non-nullable, and the first open ticket breaks it. Paste several responses, including the empty ones. - Enum-shaped strings. A status field holding
"open"and"closed"is typedstring. Promoting it to anenumwith aJsonStringEnumConverteris a modelling choice, and guessing at it from two samples would be worse than leaving it alone. - Objects used as maps. A payload where keys are ids, such as
{"a41": {...}, "b72": {...}}, generates a type with two oddly named properties. What you want isDictionary<string, Item>. The generator has no way to tell a map from a record, so make that call yourself.
When you need a contract rather than a reading, generate a JSON Schema from the same payload and treat the schema as the source of truth. Inference from examples is a fast start, not a guarantee.
A working pass, start to finish
Assume you are wiring up an API client and want the response deserialized into typed objects.
- Call the endpoint and copy a full response body. Two or three of them concatenated into an array is better, since optional fields only reveal themselves across several records.
- Paste it above, set the root type name to match the resource, and pick the style your project uses.
- Read the notes panel. Anything marked as a warning is a field where the payload did not carry enough information.
- Download the file into your project, then fix what the data could not describe: money to
decimal, status strings to anenum, any array that came through asList<object>, and any object that is really a dictionary. - Deserialize with
JsonSerializer.Deserialize<SupportTicket>(body)and run it against a handful of real responses before trusting it.
Step four is the one people skip. The generator gets you to roughly ninety percent of a usable model in a second. The last ten percent is domain knowledge no tool reads off a payload.
What this generator leaves out
- No validation attributes.
[Required],[StringLength],[Range]and the rest are absent. Nullability is the only constraint the data itself supports. - No EF Core mapping. Keys, indexes, relationships and table names are database decisions, not JSON ones.
- No source-generated serializer context. If you need trimming-friendly deserialization, add your own
JsonSerializerContextpartial class next to the generated types. - No recursive types. A tree where a node contains nodes of its own kind produces a type per level rather than a self reference. Collapse it by hand into
List<Node>on the parent. - No structs, no interfaces, no inheritance. Three shapes of type cover the common cases without producing a hierarchy you have to unpick.
- Large payloads slow the tab down. Parsing and generation run on the main thread in your browser. Files in the low megabytes are fine. A hundred megabyte export is not, and a trimmed sample of it would produce the same types anyway.
Nothing you paste is uploaded. The parser and the generator both run inside this page, so a payload holding customer records or tokens stays on your machine. Load the page once, drop your network connection, and everything above keeps working.
