JSON to Java, Kotlin and TypeScript in the Browser: What JSON2Class Infers and Where It Stops

Every code sample was produced by running app/src/lib/jsonClassCore.js from the json2class repository under Node.js 20 and pasting the output unedited; regenerated 2026-09-17 against the build that fixes array widening, Kotlin imports and the Markdown column label, with 47 core tests and 309 repository tests passing, and verified live in the deployed bundle

Disclosure. JSON2Class is my own project. I build it, I pay for its hosting, and this post exists partly to put it in front of people. That is a conflict of interest, so this article is written the way I would write about somebody else's generator: the samples are real output, and the section on what it gets wrong is longer than the section on what it gets right.

Revision note (2026-09-17). The first version of this article, published earlier the same day, listed four defects. Three of them were fixed within the hour and this text was regenerated against the fixed build: array element types now widen across every element, Kotlin output now carries its java.time and BigDecimal imports, and the Markdown table's third column is now headed "Nullable", which is what it always reported. The remaining one, TypeScript's asymmetric date mapping, is still open and still listed below. Every sample here comes from the build that is live now.

Every backend team hits the same chore. An upstream service hands you a JSON payload, your codebase wants a static type, and somebody spends twenty minutes hand-typing a DTO, getting one field name wrong, and finding out at runtime. Code generators solve this, which is why there are dozens of them. The interesting question about any particular one is not whether it converts JSON — they all do — but what it decides when the JSON is ambiguous. A single sample payload underdetermines the type: one integer could be an int, a long, or a string ID that happens to look numeric this week.

So this is a post about inference decisions, with the generated output pasted in unedited.

What one payload produces

Here is an order payload with the shapes that actually cause trouble: a float, an integer, a null, an empty array, an array of objects, and a nested object.

{
  "orderId": "ord_2026_3001",
  "total": 88000.5,
  "quantity": 3,
  "currency": "KRW",
  "note": null,
  "tags": [],
  "items": [
    { "sku": "A-123", "qty": 2, "price": 12000 },
    { "sku": "B-990", "qty": 1, "price": 64000.5 }
  ],
  "shipping": { "name": "Jane Roe", "zip": "06234", "isExpress": true }
}

The Java output, with the default Lombok annotation set:

import lombok.Getter;
import lombok.AllArgsConstructor;
import lombok.ToString;
import java.util.List;

@Getter
@AllArgsConstructor
@ToString
public class Order {
    private final String orderId;
    private final Double total;
    private final Integer quantity;
    private final String currency;
    private final Object note;
    private final List<Object> tags;
    private final List<Items> items;
    private final Shipping shipping;
}

@Getter
@AllArgsConstructor
@ToString
public class Items {
    private final String sku;
    private final Integer qty;
    private final Double price;
}

The same payload as a Kotlin data class:

data class Order(
    val orderId: String?,
    val total: Double?,
    val quantity: Int?,
    val currency: String?,
    val note: Any?,
    val tags: List<Any>,
    val items: List<Items>,
    val shipping: Shipping
)

And as a TypeScript interface:

export interface Order {
  readonly orderId?: string;
  readonly total?: number;
  readonly quantity?: number;
  readonly currency?: string;
  readonly note?: any;
  readonly tags: Array<any>;
  readonly items: Array<Items>;
  readonly shipping: Shipping;
}

Three classes, fourteen fields, one paste. There is also a Markdown tab that emits a field table per class, which is the output I use most often — it goes straight into a pull request description when an endpoint contract changes.

The decisions inside that output

Four choices are visible above, and each one is a position, not a neutral default.

Scalars are optional, containers are not. orderId becomes String? in Kotlin and orderId?: string in TypeScript, because one sample payload proves a key can be present, never that it must be. Arrays and nested objects go the other way and are non-null, because the generator knows their shape from the structure it just walked. That asymmetry is deliberate: absent scalars are the common production surprise, absent containers are usually an API bug you want to hear about.

Java gets boxed types and final fields. Integer, not int, so a missing number deserializes to null instead of a silent 0 — the single most annoying class of JSON bug. Fields are final, the class ships with @AllArgsConstructor, and if you would rather have a record, the Java tab has a record style that drops Lombok entirely:

import java.util.List;

public record Order(
    String orderId,
    Double total,
    Integer quantity,
    String currency,
    Object note,
    List<Object> tags,
    List<Items> items,
    Shipping shipping
) {}

Numbers are sized by value, not by wishful thinking. A value with a fractional part is Double, and an integer above Integer.MAX_VALUE is widened: a field holding 9007199254740 came out as private final Long views;.

ISO-8601 strings become time types. In the same run, &quot;createdAt&quot;: &quot;2026-09-17T10:15:30Z&quot; produced private final LocalDateTime createdAt; and &quot;dueDate&quot;: &quot;2026-09-30&quot; produced private final LocalDate dueDate;, with the java.time imports added to both the Java and the Kotlin output. The Jakarta and javax validation flavors are a toggle, so a Spring Boot 2 codebase gets javax.validation.constraints.NotNull and a Spring Boot 3 codebase gets jakarta.validation.constraints.NotNull — the same switch that used to eat an afternoon during a framework upgrade.

Annotations written inside the JSON

The part I would keep if I had to throw the rest away. Validation constraints are the thing a generator genuinely cannot infer, so instead of guessing, the input accepts directive lines above a field:

{
  #NotNull
  "orderId": "ord_2026_3001",
  #Min: 1
  "quantity": 3
}

That input is not valid JSON, which is the point — the parser strips the directives before handing the rest to JSON.parse. The output carries the annotations and the matching imports:

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Min;

@Getter
@AllArgsConstructor
@ToString
public class Order {
    @NotNull
    private final String orderId;
    @Min(1)
    private final Integer quantity;
}

NotNull, Min, Max and Length are supported today. The idea is that a sample payload pasted from a Slack thread can be annotated in place, in ten seconds, without leaving the tab.

What it still does not infer

Everything the conversion core does happens in the page, in your browser. Nothing is uploaded and nothing is stored server-side, which is the reason I can suggest pasting a real payload into it at all — but it also sets the honest limits below. The core is a plain ES module with no runtime dependencies and its own Node test suite; 47 tests pass on the build this post describes.

LimitationWhat happensWorkaround
TypeScript maps date-times and dates differently&quot;2026-09-17T10:15:30Z&quot; becomes Date, &quot;2026-09-30&quot; stays string, and neither survives JSON.parse as a DateTreat both as string in the interface and parse at the boundary
Elements with no common type collapse[&quot;a&quot;, 1, true] becomes List&lt;Object&gt;, and once a field is mixed a later element cannot narrow it backIntended, but it means one stray value can flatten a field; check the type before copying
A field that is only ever null stays Object&quot;note&quot;: null gives private final Object note;, because nothing in the sample says what it holdsPaste a sample where the field has a value
Very large payloadsConversion runs in browser memory, so tens of megabytes is slow or worseTrim the payload to the fields you care about first

And the limit that no generator escapes: inference only sees the sample you paste. If a field is sometimes absent, sometimes null, or sometimes a string, put those cases in the input — that is what the widening described above reads.

The array case is worth one more sentence, because it is the one that used to bite. [{&quot;price&quot;: 12000}, {&quot;price&quot;: 64000.5}] generated Integer price until this morning: sampling one element is cheap and usually right, and when it is wrong it is wrong quietly, in a currency amount. Now every element contributes — a fractional value or a larger magnitude widens the field, object elements merge into the union of their keys, and a null in one element no longer hides a type that another element supplies.

The rest of the toolbox

The same site carries the tools that tend to be open in the next tab over: a JSON diff that reports added, removed and changed fields by path; a JSON Schema generator; Avro schema and message generators with encode and decode; a Kafka message generator that emits payload, headers, key, a Java producer snippet and a kcat command; a JWT debugger that renders exp, iat and nbf as readable times; and the small change — UUID, password and API key generators, a curl builder, Base64.

One workflow is worth spelling out, because it is the one that changed how I review contract changes. When an endpoint's payload changes, paste the old response into one tab and the new one into the JSON diff, which reports the added, removed and changed fields by path rather than by line. Then paste the new payload into the converter and copy the Markdown field table into the pull request. The reviewer sees three things at once: which paths moved, what the resulting type looks like in the language they work in, and which fields the generator believes are nullable. That last column is where the disagreements surface, and surfacing them in review is much cheaper than surfacing them in a deserialization stack trace.

Try it with a payload of your own: JSON2Class. If the inference gets something wrong on your data, that is the useful kind of report — the repository is github.com/yuus95/json2class, and the contact page reaches me.

Tool I build

JSON2Class — paste a payload, get the class

Immutable Java DTOs, Kotlin data classes, TypeScript interfaces and a Markdown field table from one JSON paste. Jakarta or javax validation, records or Lombok, java.time types inferred from ISO-8601 strings.

  • Java
  • Kotlin
  • TypeScript
  • Markdown docs
Convert a payload

Runs in your browser — nothing is uploaded. Free, no account. Source