json
JSON to Go, C#, Java, GraphQL: What Each Converter Guesses
One JSON sample, five targets: how JSON to Go struct, C# class, Java class, GraphQL schema and JSDoc converters each guess what JSON can't tell them.
By Kyle Hurst · · Updated October 23, 2026
You paste the same API response into five converters and get five documents that quietly disagree about whether a field can be null: Go makes it a pointer, C# spells it long?, Java says nothing because Long was already nullable, GraphQL drops a !, and JSDoc doesn’t mention it at all. All five 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 Go struct, 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 — including the decoder rules that decide whether a wrong guess fails loudly or silently. Generating JSON Schema From a Sample is the schema-side view of the same blind spots.
One inference, five vocabularies
All five converters share one type-inference pass and then translate its result. The whole mapping fits in a table:
| Inferred from the sample | Go | C# | Java | GraphQL | JSDoc |
|---|---|---|---|---|---|
| string | string | string | String | String | string |
| number, no fractional part | int64 | long | Long | Int | number |
| number with a fractional part | float64 | double | Double | Float | number |
true / false | bool | bool | Boolean | Boolean | boolean |
null and nothing else | interface{} | object | Object | JSON | * |
| object | named struct | nested class | nested class | nested type | nested @typedef |
| array of agreeing values | []T | List<T> | List<T> | [T] | T[] |
| empty array, or mixed elements | []interface{} | 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 int64 / 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": "ada@example.com" },
"created_at": "2026-09-10T09:30:00Z"
}
Root name Account, serialization annotations on. Go:
type Account struct {
Id int64 `json:"id"`
DisplayName string `json:"display_name"`
Price int64 `json:"price"`
AvatarUrl interface{} `json:"avatar_url"`
Tags []interface{} `json:"tags"`
Owner AccountOwner `json:"owner"`
CreatedAt string `json:"created_at"`
}
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, five 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 five outputs, verbatim:
type Account struct {
Id int64 `json:"id"`
AvatarUrl *string `json:"avatar_url"`
Price float64 `json:"price"`
Seats *int64 `json:"seats"`
}
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 float64 / double / Double / Float / number in all five, because one record had a fraction — the best argument there is for pasting several records. But nullability lands in five different places. Go makes both nullable fields pointers (*string, *int64). 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.
Go: strict numbers, pointers, and omitempty
Go’s static types make several of these guesses fail louder than they do elsewhere — and one of them fail more quietly.
A whole number in the sample is an int64 that rejects 12.5. The inference classifies a number by whether it has a fractional part after parsing, and JSON.parse("1.0") yields 1, so {"price": 1.0} and {"price": 1} produce the same int64. Go’s decoder then fills an integer field with strconv.ParseInt on the raw literal text (literalStore in encoding/json/decode.go), so 12.5 — or 1.0 — is an UnmarshalTypeError. The trap is how it’s reported: the decoder saves the error, skips the field, leaves it at zero, and keeps going, returning the error only at the end. Ignore or merely log that error and a price silently becomes 0. Widen anything that could ever carry a fraction; int64 in generated output means “this was round once,” not “this is an integer.”
int64 is genuinely right for IDs, though. Because Go parses the literal text rather than going through a float, 9223372036854775807 lands in an int64 exactly. The converter’s own view of that number is already rounded — the browser parsed it as an IEEE 754 double, so anything above 2^53−1 lost precision before inference ran — but the emitted type is still correct. What int64 cannot hold is a uint64 ID above 9.22×10^18, or exponent form like 1e21, which ParseInt rejects. For an unbounded ID space use json.Number (a string type with Int64(), Float64() and String() methods) or string.
A pointer cannot tell null from absent. A key seen only as null has no type to point to, so it becomes interface{}, which unmarshals anything and pushes a type assertion into every call site. Once a real value shows up, nullable or sometimes-missing fields become *T — except slices and interface{}, which are already nil-able. But per the Unmarshal docs, a JSON null sets a pointer to nil and “has no effect” on any other type, and a missing key also leaves the field untouched. So *string is nil for both null and absent, and plain string is "" for both. If those mean different things in your domain, you need json.RawMessage or a custom UnmarshalJSON with an explicit “was it there” flag.
,omitempty is the right signal and often the wrong behavior. The converter tags fields it saw go missing with ,omitempty, but that option is defined on the Go value, not on presence: the Marshal docs omit “false, 0, a nil pointer, a nil interface value, and any array, slice, map, or string of length zero.” A legitimate 0, false or "" is dropped from your output, while a zero struct or zero time.Time is not. Go 1.24 added omitzero, which omits a value if it is zero (using an IsZero() bool method when the type has one) and, per the release notes, “unlike omitempty, omitzero omits zero-valued time.Time values.” Pick deliberately:
| What you mean | Field | Tag |
|---|---|---|
| Present, may be null | *string | json:"avatar_url" |
| May be absent entirely | *string | json:"note,omitempty" |
Struct or time.Time dropped when unset | time.Time | json:"seen_at,omitzero" (Go 1.24+) |
A 0 / false / "" that must survive | int | json:"count" — no omitempty |
Names and dates. Go only marshals exported fields, so PascalCasing is mandatory, and it gives user_id → UserId where Go convention wants UserID; the json tag carries the wire name, so that rename is for the reviewer, not the compiler. On decode the tag matters less than you’d think — Unmarshal prefers an exact key match but also accepts a case-insensitive one, so DisplayName picks up displayname on its own. CreatedAt stays a string; changing it to time.Time is right only if the producer emits RFC 3339, the one format time.Time’s JSON methods speak. A space instead of T, a bare date, or Unix seconds need a custom type with your own UnmarshalJSON.
One guess Go makes visible that applies to every target: an object like "limits": {"proj_1": 5, "proj_2": 20} becomes a struct with fields Proj1 and Proj2, and the next response’s proj_3 is silently ignored. No sample can tell a record from a dictionary. If the keys are data — IDs, dates, locales — you want map[string]int64 (or Dictionary<string, long>, Map<String, Long>); if they’re schema, keep the struct.
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 the same collapse as Go’s pointers: 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 five 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 five that keeps your wire names as the field names: 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 four rename instead: user-name becomes UserName in Go and C#, userName in Java and user_name in GraphQL, and a leading digit forces a prefix — 2fa lands as T2fa, T2fa, f2fa and _2fa. Go, C# and Java keep the original in a json tag, [JsonPropertyName] or @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 | Go | C# | Java | GraphQL | JSDoc | Your call |
|---|---|---|---|---|---|---|
12 (money) | int64 | long | Long | Int | number | decimal / BigDecimal; at least float64 in Go |
| Big ID | int64 | long | Long | Int! | number | ID or string above 2³¹; json.Number past int64 |
null seen | *T | long?, but string unchanged | unchanged | ! dropped | unchanged | Say it explicitly |
| Key sometimes absent | *T + ,omitempty | ? on value types | unchanged | ! dropped | [key] | Absent vs null vs zero |
null only | interface{} | object | Object | JSON | * | The real type |
[] or mixed array | []interface{} | List<object> | List<Object> | [JSON] | *[] | Element type |
| ISO timestamp | string | string | String | String | string | time.Time / DateTimeOffset / Instant |
"active" | string | string | String | String | string | An enum (in Go, a named type + constants) |
| Object with ID-like keys | struct | class | class | type | @typedef | A map |
user-id | json tag | [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 five outputs at once. (If your sample is a log stream, remember that NDJSON is not a JSON array and needs wrapping first.) Then read the output for the tokens that mean “I ran out of evidence”: interface{} / object / Object / JSON / *, any bare []interface{} or List<object>, every int64 / long on a value that might carry a fraction, 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 Go struct converter, JSON to C# converter, JSON to Java converter, JSON to GraphQL SDL converter or JSON to JSDoc generator — all five run entirely in your browser. The same inference drives JSON to Rust, 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.