Codegen is a tax. Sometimes it is worth paying.
Flutter release builds strip dart:mirrors, so nothing in a shipped app reads a class at runtime and writes its JSON handling for you. Python gets that for free from duck typing. JavaScript gets it for free because an object is already a bag of keys. Dart does not, so every field on the config you paste above has to be spelled out somewhere, by you or by a generator running before the app builds.
Nothing here is free. The three tabs above are three places to pay.
A plain class costs typing and nothing else. json_serializable and Freezed cost a dependency and a build step, in exchange for less code sitting in your repository and, in Freezed's case, equality and copyWith you never have to write by hand.
What build_runner buys you
Pick either generated style and the output above references functions that do not exist yet, _$AppConfigFromJson and friends. Those come from running dart run build_runner build --delete-conflicting-outputs in the project that owns this file, which reads the annotations and writes a .g.dart file next to it, a .freezed.dart file too if you picked Freezed. Skip that step and the analyzer marks the whole file red, not because the code is wrong, but because the part it depends on has not been generated yet.
Most teams keep build_runner watch running while they work, so the generated files stay current as the model changes, and gitignore the generated output so it never causes a merge conflict. A CI pipeline then runs the build once before tests. It is one more moving part in exchange for boilerplate you stop writing by hand, and whether that trade is worth it depends on how often the shape of this file changes.
| Style | Extra packages | Equality, toString | copyWith | Reach for it when |
|---|---|---|---|---|
| Plain class | None | Manual, or tick Equatable above | You write it | A small config object, or a package you do not want a build step in |
| json_serializable | json_annotation, plus build_runner as a dev dependency | Not included | You write it | A class that already exists and only needs the JSON glue added |
| Freezed | freezed_annotation, plus freezed and json_serializable as dev dependencies | Generated | Generated | A model you compare often, or pass through setState and equality-sensitive widgets a lot |
Field names collide with what Dart already claims
A YAML key can be almost any string. A Dart field cannot. Convention also expects camelCase, not the kebab-case that reads naturally in a config file nobody writes Dart in, so base-url becomes baseUrl before it reaches the class at all. Two harder cases show up in the sample above.
class: premium
2fa-required: truefinal String class_;final bool k2faRequired;factory AppConfig.fromJson(Map<String, dynamic> json) => AppConfig(class_: json['class'] as String,k2faRequired: json['2fa-required'] as bool,);class is a reserved word in Dart, so it cannot be a field name at all, not even inside a class named something else. The trailing underscore is the smallest change that still compiles. 2fa-required breaks a different rule, since no Dart identifier may open with a digit, so the field picks up a k prefix instead. In the plain class style neither rename costs you anything at the call site, because json['class'] still points at the original key. Switch to json_serializable or Freezed and the same rename needs an explicit @JsonKey(name: 'class') above the field, or the generated code would look for a key spelled class_ in the response and never find it.
Why doesn't my release date show up as a DateTime?
Because nothing in a Map<String, dynamic> knows what a date is. json.decode and the YAML reader above both produce strings, numbers, booleans, lists, maps and null, full stop. An unquoted 2026-08-30 in the YAML file is recognised as a date while this tool is reading the file, but the moment it becomes a field on a JSON-shaped model, it is a plain String unless something calls DateTime.parse on the way in.
Tick Parse ISO dates as DateTime and that call gets written for you, matched with .toIso8601String() on the way back out. Leave it off and the field stays String, which is the honest default for a value most services hand you as text anyway.
DateTime.parse reads a string with no timezone offset as local time, not UTC. A field written 2026-08-30T09:00:00 without a trailing Z parses to 9 AM wherever the device happens to be, which is rarely what a server meant. Append the offset in the source file, or call .toUtc() yourself once the value is parsed, if the config is meant to carry an absolute instant.
Lists that disagree with each other
The three entries under feature-flags in the sample do not all carry the same keys. Two have rollout, one does not. Rather than reading only the first entry and guessing, the generator folds every entry into one shape: a key present everywhere stays required, a key present in some entries but not others comes through nullable, and the notes panel below the editors names which key and how many entries were short.
An empty list carries no evidence about what it holds, so it types as List<dynamic> until a populated sample says otherwise. A list mixing a real value with a bare null also falls to dynamic, on purpose rather than by omission: whether that null means "not applicable" or "not loaded yet" depends on the app, and typing it either way here would be a guess dressed up as an answer.
Where this reader draws the line
- No anchors or merge keys.
&defaultsand<<: *defaultsturn up constantly in deploy manifests and rarely in the pubspec-shaped config a Flutter app carries, so this reader leaves them as unrecognised text instead of resolving them. A file built around anchors reads more accurately through the YAML to Go or YAML to Python converter, both of which expand them before typing. - No block scalars. A value opened with
|or>comes through as the two characters on that line, not the folded multi-line string underneath. Keep multi-line notes out of the file you paste here, or quote them on one line instead. - One document. A file split with
---is read as a single continuous block rather than separate documents. Trim the file to the part you want typed if it carries more than one. - Enum-shaped strings stay String. A key like
providermight only ever holdfirebaseorsupabasein your app, but one sample cannot prove the full set, so it stays a plainStringrather than becoming a Dart enum you would have to hand-maintain against a guess. - Nothing is uploaded. Parsing and code generation both run in this tab after the page loads. A config carrying an API base URL or a session length never leaves your browser.
A JSON response fits the JSON to Dart converter better than this one, since it infers from response shapes instead of a config file. A YAML file that already has a schema is worth running through the YAML to JSON Schema converter before anything gets typed by hand. And a file that will not parse here at all deserves a pass through the YAML validator first, since it lists every problem at once instead of stopping at the first.
