JSON to Dart Model Generator

An endpoint returns a shape nobody documented and Flutter wants a typed class before it will show a single widget. Paste the response, read the model classes and check the field map underneath to see which keys the generator decided are nullable and why.

CtrlEnter regenerates
JSONone record, or an array of them
Dartread only, copy or download it

Field map

Every key the generator saw, the Dart name it took, the type it settled on, and whether it was left nullable. Disagree with a row and the JSON you pasted is the place to fix it.

One response is a sample, not a contract

JSON carries no schema. A generator reading a single record sees the values the server happened to send that afternoon, and everything it prints is an inference from that one snapshot. The keys it never saw are keys it will never model, and a field holding a value today looks required forever.

So paste more than one record. Hand the page an array and every element gets merged into one shape before any Dart is written. A key missing from some elements comes back nullable. A key holding a string in one and a number in another falls back to dynamic rather than picking a winner and crashing later.

The sample loaded above shows both rules working. Look at the two entries under endpoints:

"endpoints": [{ "host": "api.toolexe.com", "region": "eu-west-1", "latency_ms": 41 },{ "host": "cdn.toolexe.com", "region": "us-east-1", "latency_ms": 12.7, "notes": null }]

notes appears in the second entry only, so Endpoint.notes is optional in the constructor. It is also null in the one place it appears, which leaves no type to read off it, so it lands as dynamic. Send one non-null example and the field map sharpens immediately. The same goes for archived_at at the top level, null in the sample and typeless because of it.

int against double, and the cast that throws in production

The two latency values are the interesting part. One record sends 41, the other sends 12.7. A generator looking only at the first record types the field int, ships, and dies the first time a decimal arrives. A generator looking only at the second types it double, and dies just as fast, because Dart decodes 41 to an int and json['latency_ms'] as double throws on it.

Merging both records gives num, the supertype both sides satisfy. When you know the field is always a decimal, the safe cast is the two step version this page emits for monthly_cost:

monthlyCost: (json['monthly_cost'] as num).toDouble(),

Casting through num first accepts whatever the decoder produced, then converts. Write as double and you have bet the parse on a server never sending a round number.

Flutter web behaves differently again. Compiled to JavaScript, every number is a 64 bit float, so 41.0 is int returns true there and false on mobile. Code with a whole number path and a decimal path is worth testing on both targets before you trust it.

Three output shapes, and the cost of each

  • Manual fromJson and toJsonNo package, no code generation, no build step. The parsing is right there in the file where a reviewer reads it and a debugger steps through it. Best for a handful of models, or any project where adding build_runner is more trouble than writing forty lines.
  • json_serializable annotationsDeclares the fields and lets a builder write the parsing into a .g.dart part file. Worth the setup once the model count passes roughly ten, since regeneration keeps every class consistent when the API shifts. Requires json_annotation and build_runner in pubspec, and the output does not compile until the builder has run.
  • Equatable with value equalityAdds a props list so two instances holding the same values compare equal. Bloc and most state management rely on that comparison to decide whether a rebuild is needed, and without it every emitted state looks new. Manual parsing is included, so this shape needs no build step either.

The copyWith toggle applies to all three. It writes a method taking every field as an optional named argument and falling back to the current value, the standard way to update one field on an immutable model. One caveat worth knowing before you rely on it: passing null to a copyWith argument reads as "leave this alone", so the pattern cannot clear a nullable field back to null. Clearing needs a sentinel value or a hand written setter.

What the field map is telling you

Snake case keys become camel case fields because Dart lints reject created_at as a field name. The renamed badge in the map marks every key where the two spellings differ, and the two output shapes handle the gap differently.

Manual and Equatable output keeps the original string inside the parser, so json['created_at'] reads the wire format directly and no annotation is needed. The json_serializable shape has no parser to put the string in, so it carries a @JsonKey(name: 'created_at') line above the field instead. Delete that annotation and the builder will look for a key called createdAt and find nothing.

Keys colliding with Dart keywords get a suffix. A JSON key called class becomes classField, since the bare word will not compile as an identifier. Nested objects are named after the key holding them, and a key holding a list is made singular first, so endpoints produces a class called Endpoint rather than Endpoints. Two nested objects sharing a key name and the same set of field names and types reuse one class instead of generating a near duplicate.

Date detection is a guess you should check

With the ISO toggle on, any string matching the YYYY-MM-DD shape, with or without a time and offset, becomes a DateTime and gets a DateTime.parse call in the parser. Turn the toggle off and those fields stay String.

Two failure modes are worth planning for. Unix timestamps arrive as integers, so 1773465164 is typed int and stays one, and converting it needs DateTime.fromMillisecondsSinceEpoch(value * 1000) written by hand. And DateTime.parse throws a FormatException rather than returning null, so a single malformed row takes the whole response down. When the upstream date format is inconsistent, keep the field as a String and parse it where you have somewhere to put the error.

The parse also drops the offset. DateTime.parse returns a UTC instant for a string ending in Z and a local time otherwise, so a timestamp carrying +05:00 loses the fact it was written in Karachi. Reach for the timezone package when the original zone matters to the display.

Where this stops

  • No freezed output. Unions, sealed classes and generated copyWith with sentinel handling need the real builder.
  • String fields holding a fixed set of values are typed String, never an enum. Nothing in one response proves the set is closed.
  • Generic models, inheritance and mixins are out of reach. Every class here is flat and standalone.
  • An empty array gives List<dynamic>, because zero elements carry zero type information.
  • A field always sent whole reads as int. If the API is documented to return a decimal, widen the type by hand or paste a record proving it.
  • Deeply nested responses produce a long file. Splitting it across files and adding imports is left to you.
  • Pastes over 1 MB are refused rather than freezing the tab. One representative record beats a full page of results.

Everything runs in the tab. The JSON is parsed by the browser and never posted anywhere, so an authenticated response with real customer rows in it is safe to paste here in a way it is not on a page that sends the body to a server.

Wiring the model into a request

Generated classes handle the shape. The call around them stays yours:

final response = await http.get(Uri.parse('https://api.toolexe.com/v1/workspaces/wkspc_7f31')); if (response.statusCode != 200) {throw Exception('Workspace lookup failed with ${response.statusCode}');} final workspace = Workspace.fromJson(jsonDecode(response.body) as Map<String, dynamic>);

Decode on a background isolate once the payload grows past a few hundred kilobytes, since jsonDecode blocks the frame it runs on and a stutter during a list load is the usual symptom:

final rows = await compute(parseWorkspaceList, response.body);

Picking the json_serializable shape adds two commands. Run the first once, and the second whenever a model changes:

flutter pub add json_annotation flutter pub add --dev build_runner json_serializable dart run build_runner build --delete-conflicting-outputs

The --delete-conflicting-outputs flag is the fix for the error every Flutter developer meets on their second run, when a stale .g.dart file blocks the new one.

JSON to Dart questions

Why is a field typed dynamic instead of String?

Because every record you pasted had null in it. A null value proves the key exists and proves nothing about its type, so the generator refuses to invent one. Paste a record where the field holds a real value and the type resolves on the next keystroke.

Should I paste one record or the whole array?

The array, up to the 1 MB limit. Elements are merged before any code is written, so optional keys come back nullable and a field mixing whole numbers with decimals comes back as num. A single record gives you a model that matches one afternoon of API behaviour.

Which output shape should a new Flutter project use?

Manual, until the model count makes it tedious. It compiles the moment you paste it, needs no dependency and shows a reviewer exactly what is being parsed. Move to json_serializable when regenerating twenty classes after an API change beats editing them by hand.

The generated code will not compile. What is missing?

With the json_serializable shape, the part file. The class references _$ModelFromJson, which lives in the .g.dart file that build_runner writes, so the code stays red until that command has run. With Equatable, check that the equatable package is in pubspec.

Does turning null safety off help with an old project?

It drops every question mark and every required keyword, which matches Dart 2.11 and earlier. Any project on a current Flutter release wants the toggle left on, since sound null safety is what stops an absent key becoming a runtime error three screens later.

Is my JSON uploaded anywhere?

No. Parsing and code generation are JavaScript running inside this page, so a response carrying tokens, invoices or customer rows never leaves your browser.

Can it generate freezed classes?

No, and that is deliberate. Freezed models depend on a builder for their unions and their copyWith semantics, so a page printing the annotations without running the builder would hand you code that does not compile. Take the manual output and adapt it, or write the freezed skeleton and paste the field list across.