1. Why map struct ↔ JSON
When you first start writing a program, it seems logical to work “directly”: here is a JSON object, here is the key "name", here is j["name"]. And at first it's even pleasant: fast, without extra layers, it compiles, and you feel like the master of the data.
The problem starts in the second week of the project's life (that is, roughly 15 minutes after the first successful run). Keys spread across the code, “magic strings” appear, someone writes "userName", someone "username", and someone "user_name" — and all of that is the same field, but in different universes. Mapping is a way to gather transformation rules in one place and stop playing “find the typo”.
Lecture goal: learn how to choose between the two approaches and understand the selection criteria, not act by “because that's how it's done”.
Two approaches: manual and via to_json/from_json
It's important to remove fear right away: both approaches are valid. Manual serialization is not a “beginner's shame”, but a tool that is sometimes actually better. And to_json/from_json is not “magic”, but discipline: you agreed where keys and rules live.
The difference is roughly like “cooking from a recipe” versus “cooking by eye”. By eye sometimes is faster and tastier — but if you want to repeat the dish in a month, you suddenly discover that everyone's “a pinch of salt” is different, and “fry until done” is not checked by the compiler.
First we will look at the manual approach, then at to_json/from_json, and then we will form clear selection criteria.
2. Manual serialization
Manual serialization is when you explicitly create nlohmann::json and explicitly read from it. Usually it looks like two functions: serializeXxx(...) and parseXxx(...). The advantage of this approach is that it is straightforward and easy to debug: you literally see what you put where.
The downside appears later: read and write logic starts to duplicate, keys spread, and format changes require editing many places. But for small structs and one-off formats the manual approach often wins on simplicity.
Mini example of manual serialization for Task
Suppose we have a learning app “TaskBox” — a console task manager we have developed since we learned struct, vector, files, and input checks. No format versions and migrations yet (that will come later), just saving a list of tasks to JSON.
#include <nlohmann/json.hpp>
#include <string>
struct Task {
int id{};
std::string title;
bool done{};
};
nlohmann::json serialize_task(const Task& t) {
return nlohmann::json{{"id", t.id}, {"title", t.title}, {"done", t.done}};
}
Code is short and clear. It's easy to set a breakpoint here and inspect the JSON tree.
Manual deserialization
Now read it back. And here the first fork appears: you do strict (required fields via at()), or more lenient (via value() with defaults). Validation is a separate lecture, so now we show “strict and simple”, without fancy defaults.
#include <nlohmann/json.hpp>
#include <string>
Task parse_task_strict(const nlohmann::json& j) {
Task t;
t.id = j.at("id").get<int>();
t.title = j.at("title").get<std::string>();
t.done = j.at("done").get<bool>();
return t;
}
This works, but there is a nuance: if the structure is different or the field type is different — an exception will be thrown. At the “make it work” stage this is fine. At the “make it not crash on garbage” stage — validation is needed (next lecture).
Where manual approach starts to hurt
The pain doesn't come from JSON itself, but from the human factor.
You added a priority field to Task. Then you forgot to update serialize_task. Or you updated serialization but forgot parsing. Or you updated everything but wrote the key "prio" somewhere because “it's faster”. And that's it: data starts being saved “weirdly”.
Manual approach becomes fragile when:
- the struct is used in many places,
- the format lives long,
- there are several entities,
- there are nested models (Project contains vector<Task>),
- there are optional fields and you need a single rule.
4. to_json/from_json: rules next to the type
The to_json/from_json approach in nlohmann::json is a way to say: “For type Task here are the rules how to turn it into JSON and back”. Then outer code becomes cleaner: you can write json j = task; and Task t = j.get<Task>();.
This is not “automatic serialization from thin air”. You still write rules by hand, you just do it in a standardized place and get a single interface.
Key idea: don't spread keys across the project, keep them next to the model (or in a model serialization module).
Example of to_json/from_json for Task
#include <nlohmann/json.hpp>
#include <string>
struct Task {
int id{};
std::string title;
bool done{};
};
void to_json(nlohmann::json& j, const Task& t) {
j = nlohmann::json{{"id", t.id}, {"title", t.title}, {"done", t.done}};
}
void from_json(const nlohmann::json& j, Task& t) {
t.id = j.at("id").get<int>();
t.title = j.at("title").get<std::string>();
t.done = j.at("done").get<bool>();
}
Note: logic is almost the same as in the manual approach. The difference is that now the library can call these functions automatically.
How it looks “from outside”
#include <nlohmann/json.hpp>
#include <iostream>
int main() {
Task t{1, "Write JSON lecture", false};
nlohmann::json j = t;
std::cout << j.dump() << '\n'; // {"done":false,"id":1,"title":"Write JSON lecture"}
Task back = j.get<Task>();
std::cout << back.id << ' ' << back.title << '\n'; // 1 Write JSON lecture
}
This is the main advantage: external code stops knowing about keys "id", "title", "done". It works in terms of the Task type.
5. Selection criteria
In a real project you don't choose “once and for all”. Sometimes you make to_json/from_json for core models, and manual serialization for some technical “packages” or temporary formats.
To avoid guessing, it's useful to keep criteria as a table. It's not law, but a cheat sheet for the brain.
| Criterion | Manual serialization | to_json/from_json |
|---|---|---|
| Project size | Good for small, one-off places | Good for medium and large |
| Number of models | 1–2 models is still tolerable | The more models — the more beneficial |
| Risk of “magic strings” | High, keys easily spread | Low, keys are localized |
| External code | Often forced to know keys | Works with types (Task, Project) |
| Testing | You will have to test functions manually | You also test, but the interface is unified (get<T>) |
| Flexibility | Very flexible | Also flexible, but sometimes you want separate functions |
| Error handling | Completely on you | Also on you, but entry points are standardized |
Now let's translate the table into plain language.
Manual approach usually wins when you write a small script/utility, you have one JSON input and one output, and you're sure the format won't live for years. to_json/from_json wins when the format becomes part of the application, when data is saved to a file and must be read tomorrow, the day after, and after your summer break.
6. Useful mapping nuances
Key localization: one truth for the whole project
Even if you use to_json/from_json, keys are still strings. A good practice is to make keys live in one place and be used uniformly.
This is especially important when you have several developers or when you yourself will forget after a week what you named the field (and you will forget — don't doubt it — it's not a bug, it's a feature of working memory).
Trick: namespace keys
#include <string_view>
namespace task_keys {
inline constexpr std::string_view id = "id";
inline constexpr std::string_view title = "title";
inline constexpr std::string_view done = "done";
}
Now in from_json and to_json you use task_keys::id rather than "id".
Using keys in from_json
#include <nlohmann/json.hpp>
void from_json(const nlohmann::json& j, Task& t) {
t.id = j.at(task_keys::id).get<int>();
t.title = j.at(task_keys::title).get<std::string>();
t.done = j.at(task_keys::done).get<bool>();
}
Yes, it's a couple of characters longer. But when renaming a key you fix it in one place, not in ten.
optional and the contract: “no field” vs “field = null”
You will sooner or later face std::optional, because real data is not always complete. And here it's important not just “how to write code”, but which contract you choose.
Within mapping you have two sensible policies.
First policy: “if the optional is empty — the field is absent in JSON”. This is usually the most convenient and compact option.
Second policy: “the field is always present, but sometimes it equals null”. This is sometimes needed if you want to distinguish “no field” from “explicitly cleared field”.
For now (before lectures on validation and defaults) we'll fix the first policy because it's simpler and works well for most application formats.
Example: optional<std::string> note
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
struct Task {
int id{};
std::string title;
bool done{};
std::optional<std::string> note;
};
void to_json(nlohmann::json& j, const Task& t) {
j = nlohmann::json{{"id", t.id}, {"title", t.title}, {"done", t.done}};
if (t.note) {
j["note"] = *t.note;
}
}
Reading back
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
void from_json(const nlohmann::json& j, Task& t) {
t.id = j.at("id").get<int>();
t.title = j.at("title").get<std::string>();
t.done = j.at("done").get<bool>();
if (j.contains("note")) {
t.note = j.at("note").get<std::string>();
} else {
t.note.reset();
}
}
Semantic point: contains("note") checks for absence of the field. is_null() — a different state, which we will discuss separately in “validation and defaults”.
Nested structs and containers: domino effect
Once you've set up mapping for one type, the standard library and nlohmann::json allow you to “scale” this to containers. This is a very nice effect: you described rules for Task, and suddenly vector<Task> also starts serializing almost by itself.
It's like dominoes, but without dramatic production failures (though I wouldn't swear by it).
“Project” model with tasks
#include <string>
#include <vector>
struct Project {
std::string name;
std::vector<Task> tasks;
};
to_json/from_json for Project
#include <nlohmann/json.hpp>
void to_json(nlohmann::json& j, const Project& p) {
j = nlohmann::json{{"name", p.name}, {"tasks", p.tasks}};
}
void from_json(const nlohmann::json& j, Project& p) {
p.name = j.at("name").get<std::string>();
p.tasks = j.at("tasks").get<std::vector<Task>>();
}
Note the line {"tasks", p.tasks} and get<std::vector<Task>>(). This works because Task already has to_json/from_json.
7. Practical scenario: saving and loading TaskBox
Let's assemble the pieces into a complete picture. We don't make “perfect production” code, we make a learning, readable version: save a project to a JSON file and load it back. Files and streams we've had before, JSON parsing too, and today we make sure the code doesn't become a mess of keys.
Below are a few short functions — that's exactly how it's written in reality: small pieces so the brain doesn't overheat.
Saving Project to a file
#include <nlohmann/json.hpp>
#include <fstream>
#include <string>
bool save_project(const Project& p, const std::string& path) {
std::ofstream out(path);
if (!out) return false;
nlohmann::json j = p;
out << j.dump(2);
return true;
}
Beauty is in one line: nlohmann::json j = p;. All the “dirty” key details stayed inside to_json.
Loading Project from a file
#include <nlohmann/json.hpp>
#include <fstream>
#include <string>
bool load_project(Project& p, const std::string& path) {
std::ifstream in(path);
if (!in) return false;
nlohmann::json j;
in >> j;
p = j.get<Project>();
return true;
}
This version is “optimistic”: it assumes that the format is correct. If the format is incorrect, get<Project>() might throw an exception — and we will tidy that up in the next lecture (validation + defaults).
How it would look without mapping
To feel the selection criterion in practice, imagine that load_project instead of j.get<Project>() does this:
- j.at("name")...
- j.at("tasks")...
- a loop over the array
- inside the loop task.at("id")... and so on
And all of this directly inside the file loading function. It works, but functions become long, hard to read, and worst of all — the “format” ends up spread across the code. That is, the format is no longer the “application contract”, but a “side effect of how you wrote the loop today”.
Where to place to_json/from_json in the project
You really want a universal folder recipe, but our course goes step by step, and big-picture architecture will be separate. For now the important discipline is: make sure rules live in one place.
For a learning project you can keep the struct Task and its to_json/from_json together in one .cpp (or even in one main.cpp, while the project is small). Later, when you split into .hpp/.cpp, the logic becomes: the model is declared in the header, and serialization is either next to it (if it's “part of the public contract”), or in a separate serialization module (if you want to separate the domain model from the storage format).
The only important thing is to avoid a “halfway state” where to_json is in one place, from_json in another, and nobody is sure they're symmetric. It's like writing an “entrance” to a maze but forgetting the exit.
8. Typical mistakes
Mistake #1: mixing serialization with business logic.
Sometimes there's a temptation: “Since I'm reading JSON, let's also check here that id > 0, and if not — fix it”. As a result serialization starts to make domain decisions: substitute values, “heal” data, quietly insert defaults. This is convenient until the moment you try to figure out where a task with id = 0 came from. Serialization should map the format, not change the meaning of the data.
Mistake #2: keys spreading across the code (“magic strings”).
Even with to_json/from_json available, a developer may accidentally start reading JSON directly in other places: “I just want to look at "name" here”. Later there are ten such places, then you rename the key, update to_json, and half the code keeps reading the old key. If you chose mapping — try to make external code work with the struct, not with keys.
Mistake #3: asymmetry — “we write one thing — read another”.
Classic: in to_json you wrote "done", but in from_json you read "is_done". The compiler doesn't complain: both strings are valid. The error appears only at runtime. Therefore it's useful to centralize keys (at least via namespace keys) and, if possible, keep serialization and deserialization close together to visually ensure symmetry.
Mistake #4: treating from_json as validation.
from_json is “move values from JSON to fields”. It can throw if types don't match, but it is not obliged to check that a string is not empty, that id is in range, or that an array isn't too large. Validation is a separate topic and a separate responsibility layer. If you try to shove all validation into from_json, it will bloat and become hard to read.
Mistake #5: confusing “no field” and “field = null” with optional.
If today you treat optional as “no field”, and tomorrow you sometimes write null, then in a week you'll have three states instead of two, and nobody will remember what they mean. Choose a contract (for example, “optional → no field”) and stick to it consistently. If you need null — that's fine too, but then agree on it explicitly and everywhere.
GO TO FULL VERSION