json
JSON to C#, Java, GraphQL: What Each Converter Guesses
One JSON sample, four targets: how a JSON to C# class, Java class, GraphQL schema and JSDoc typedef converter each guess what JSON can't tell them.
You paste the same API response into four converters and get four documents that quietly disagree about whether a field can be null: C# spells it long?, Java says nothing because Long was already nullable, GraphQL drops a !, and JSDoc doesn’t mention it at all. All four generators ran the same inference over the same sample — what differs is which guesses the target language forces you to write down, and which ones it lets you leave unsaid. The ones it lets you leave unsaid are the ones that ship.
This post runs one realistic sample through our JSON to C# class, JSON to Java class, JSON to GraphQL schema and JSON to JSDoc typedef converters, prints the output verbatim, and goes through what each target does with the gaps. It’s the sibling of JSON to Go Struct: What the Converter Can’t Know — same blind spots, four more type systems — and Generating JSON Schema From a Sample is the schema-side view.
One inference, four vocabularies
All four converters share one type-inference pass and then translate its result. The whole mapping fits in a table:
| Inferred from the sample | C# | Java | GraphQL | JSDoc |
|---|---|---|---|---|
| string | string | String | String | string |
| number, no fractional part | long | Long | Int | number |
| number with a fractional part | double | Double | Float | number |
true / false | bool | Boolean | Boolean | boolean |
null and nothing else | object | Object | JSON | * |
| object | nested class | nested class | nested type | nested @typedef |
| array of agreeing values | List<T> | List<T> | [T] | T[] |
| empty array, or mixed elements | List<object> | List<Object> | [JSON] | *[] |
Note the first collapse already: 1 and 1.0 are the same JSON number, so a whole number always becomes long / Long / Int / number however the producer meant it. Nullability is inferred per key — a key that was ever null, or was missing from one record in a sample array, is marked nullable or optional — and then each emitter decides whether it has a way to say that.
The sample
{
"id": 412,
"display_name": "ada",
"price": 12,
"avatar_url": null,
"tags": [],
"owner": { "id": 7, "email": "[email protected]" },
"created_at": "2026-09-10T09:30:00Z"
}
Root name Account, serialization annotations on. C#:
public class Account
{
[JsonPropertyName("id")]
public long Id { get; set; }
[JsonPropertyName("display_name")]
public string DisplayName { get; set; }
[JsonPropertyName("price")]
public long Price { get; set; }
[JsonPropertyName("avatar_url")]
public object AvatarUrl { get; set; }
[JsonPropertyName("tags")]
public List<object> Tags { get; set; }
[JsonPropertyName("owner")]
public AccountOwner Owner { get; set; }
[JsonPropertyName("created_at")]
public string CreatedAt { get; set; }
}
Java:
public class Account {
private Long id;
@JsonProperty("display_name")
private String displayName;
private Long price;
@JsonProperty("avatar_url")
private Object avatarUrl;
private List<Object> tags;
private AccountOwner owner;
@JsonProperty("created_at")
private String createdAt;
}
GraphQL SDL:
type Account {
id: Int!
display_name: String!
price: Int!
avatar_url: JSON
tags: [JSON]!
owner: AccountOwner!
created_at: String!
}
JSDoc:
/**
* @typedef {Object} Account
* @property {number} id
* @property {string} display_name
* @property {number} price
* @property {*} avatar_url
* @property {*[]} tags
* @property {AccountOwner} owner
* @property {string} created_at
*/
(Each emits the nested AccountOwner type too, cut here for length.) Same seven facts, four different sets of lies.
The nullability test
Give the converters two records instead of one and the differences get sharp — here avatar_url and seats are both nullable:
[
{ "id": 412, "avatar_url": null, "price": 12, "seats": 3 },
{ "id": 413, "avatar_url": "https://x/y.png", "price": 12.5, "seats": null }
]
The four outputs, verbatim:
public class Account
{
[JsonPropertyName("id")]
public long Id { get; set; }
[JsonPropertyName("avatar_url")]
public string AvatarUrl { get; set; }
[JsonPropertyName("price")]
public double Price { get; set; }
[JsonPropertyName("seats")]
public long? Seats { get; set; }
}
public class Account {
private Long id;
@JsonProperty("avatar_url")
private String avatarUrl;
private Double price;
private Long seats;
}
type Account {
id: Int!
avatar_url: String
price: Float!
seats: Int
}
/**
* @typedef {Object} Account
* @property {number} id
* @property {string} avatar_url
* @property {number} price
* @property {number} seats
*/
price widened to double / Double / Float / number in all four, because one record had a fraction — the best argument there is for pasting several records. But nullability lands in four different places. C# marks the value type (long?) and leaves the reference type alone. Java marks nothing, because boxed Long and String were already nullable. GraphQL flags both, dropping the ! from avatar_url and seats. JSDoc loses the information entirely: there is no nullable marker in its output at all.
C#: long for everything, and a string that lies
long is a guess about the past. Any whole number becomes long, any fractional one double. For money that’s wrong twice over: double is binary floating point, so a price round-trips as an approximation. Use decimal, which System.Text.Json reads straight from the literal.
string is not string?. Reference types never pick up a ?, so a nullable field compiles clean under nullable reference types and then hands you a null through a non-nullable property. System.Text.Json ignores nullability annotations by default anyway; .NET 9 added JsonSerializerOptions.RespectNullableAnnotations to enforce them, opt-in so it doesn’t break existing apps. Until you turn it on, string means nothing to the serializer — write string? for the humans.
The [JsonPropertyName] attribute is load-bearing. Unlike Go, where Unmarshal falls back to a case-insensitive key match, System.Text.Json is case-sensitive by default — Newtonsoft was case-insensitive, which is why this bites on migration. display_name will not bind to DisplayName on its own. (ASP.NET Core configures camel-casing and case-insensitive matching for you; a plain JsonSerializer.Deserialize does not.) Turn the converter’s “Serialization annotations” toggle off only if you’re setting a naming policy globally; under Newtonsoft the attribute is [JsonProperty("display_name")].
And created_at stays a string for a reason. System.Text.Json parses DateTime/DateTimeOffset against the extended ISO 8601-1:2019 profile only — uppercase T and Z required, a space between date and time rejected, JsonException on anything else. 2026-09-10T09:30:00Z is fine; 2026-09-10 09:30:00 is not, and Unix seconds need a custom JsonConverter. Prefer DateTimeOffset, since DateTime throws the offset away.
Java: the POJO won’t deserialize as written
The Java output is the most correct on types and the least runnable as-is.
Boxed types everywhere — Long, Double, Boolean — is the right call, because a primitive long can’t say “absent.” The cost is Go’s pointer collapse again: null and “key not present” both arrive as null, and no field declaration distinguishes them.
The blocker is visibility. Jackson’s default auto-detection sees only public fields and only public getters (setters are detected at any access level, but the generated class has none). A class of bare private fields therefore exposes zero properties, and both directions fail by default: serializing throws because FAIL_ON_EMPTY_BEANS is on, deserializing throws because every key is unknown and FAIL_ON_UNKNOWN_PROPERTIES is on too. Fix it with Lombok’s @Data, a Java record, real getters and setters, @JsonAutoDetect(fieldVisibility = ANY), or mapper.setVisibility(PropertyAccessor.FIELD, Visibility.ANY).
The rest of the Java checklist:
@JsonPropertyis emitted only when the camelCase name differs from the JSON key —idandownerget none because they don’t need one. Correct but fragile: rename the field later and the wire contract silently moves.Doublefor money is the same mistake asdoublein C#. UseBigDecimal, which Jackson fills from the literal text.String createdAt→InstantorOffsetDateTimeneeds the JSR-310 module registered:mapper.registerModule(new JavaTimeModule())ormapper.findAndRegisterModules(); without it Jackson does not knowjava.timeat all. Prefer them overLocalDateTime, which has no offset and will happily lose theZ.- Add
@JsonIgnoreProperties(ignoreUnknown = true)if the producer may add fields, unless you want deserialization to fail on new ones.
GraphQL: ! is a promise, and Int is 32 bits
SDL is the only one of the four where the generated file is a public contract, and it’s the one being inferred most aggressively.
The spec is blunt about the default: “By default, all types in GraphQL are nullable; the null value is a valid response for all of the above types.” Nullability is the absence of !, so the converter decides from the sample — and it decides that anything never observed null is non-null. ! on a field that turns out to be null is a runtime error, and a null in a non-null field propagates up to the nearest nullable parent, potentially nulling out a whole object. Non-null is a promise you make forever, too: removing a ! later breaks clients. Start nullable and tighten deliberately.
id: Int! is the second trap. The spec defines Int as “a signed 32-bit numeric non-fractional value” and says that if the value is below −2³¹ or at or above 2³¹, “a field error should be raised” on the way out and “a request error should be raised” on the way in. Snowflake IDs, Unix-millisecond timestamps and most auto-increment IDs past two billion all break that. The spec’s own advice: “Numeric integer values larger than 32-bit should either use String or a custom-defined Scalar type.” ID works too — it “is serialized in the same way as a String.”
Two more edits before that file is a schema:
JSONis not a built-in scalar. The built-ins areInt,Float,String,BooleanandID; anything untypable becomesJSON, and the schema won’t validate until you addscalar JSON— or better, a real type.tags: [JSON]!is one of four spellings. A list’s nullability is independent inside and out:[T],[T]!,[T!],[T!]!. The converter only ever emits the outer!, so element non-nullability is yours to add.
Field names come through as display_name, which is valid GraphQL but not conventional, and unlike C# and Java there is no annotation to carry the wire name — rename it to displayName and the mapping lives in your resolver.
JSDoc: optional is expressed, nullable is not
JSDoc has the sneakiest gap. Optionality is emitted: a key missing from one record in a sample array comes out as @property {string} [note], which TypeScript’s checkJs reads as optional. Nullability is not emitted at all — there is no {?string} in the output, ever.
It is also the only one of the four that keeps your wire names intact: a JSON key that isn’t a JavaScript identifier comes back quoted, and optional-plus-quoted nests the two markers.
/**
* @typedef {Object} Account
* @property {string} "user-name"
* @property {boolean} "2fa"
* @property {string} ["x-trace"]
*/
The other three rename instead: user-name becomes UserName, userName and user_name, and a leading digit forces a prefix — 2fa lands as T2fa, f2fa and _2fa. C# and Java keep the original in [JsonPropertyName]/@JsonProperty; GraphQL has nowhere to put it. The one JSDoc thing left to fix by hand is @property {*}, the “we have no idea” type — under checkJs it behaves like any, disabling checking rather than flagging it.
What you actually have to decide
| In the sample | C# | Java | GraphQL | JSDoc | Your call |
|---|---|---|---|---|---|
12 (money) | long | Long | Int | number | decimal / BigDecimal |
| Big ID | long | Long | Int! | number | ID or string above 2³¹ |
null seen | long?, but string unchanged | unchanged | ! dropped | unchanged | Say it explicitly |
| Key sometimes absent | ? on value types | unchanged | ! dropped | [key] | Absent vs null vs zero |
null only | object | Object | JSON | * | The real type |
[] or mixed array | List<object> | List<Object> | [JSON] | *[] | Element type |
| ISO timestamp | string | String | String | string | DateTimeOffset / Instant |
"active" | string | String | String | string | An enum |
user-id | [JsonPropertyName] | @JsonProperty | renamed, no mapping | quoted key | Where the wire name lives |
What to do
Paste several records, not one — an array of three real responses resolves nullability, optionality and int-vs-float in one pass, and it’s the only input change that improves all four outputs at once. Then read the output for the tokens that mean “I ran out of evidence”: object / Object / JSON / *, any bare List<object>, and every ! the converter granted itself. (To see what keys a big sample actually contains first, the JSON path finder lists them all — JSONPath vs jq covers that side.)
Run your sample through the JSON to C# converter, JSON to Java converter, JSON to GraphQL SDL converter or JSON to JSDoc generator — all four run entirely in your browser. The same inference drives JSON to Go, JSON to Kotlin and JSON to TypeScript, each with the same blind spots in different clothes, and the JSON Schema generator gives the validation-side view — the schema post and the Pydantic post cover hardening that end. In every language the output is a typed skeleton that agrees with one document you happened to have. The contract is still yours to write.