Phase 1 · Objects
Build a JSON library
By the end of this page you'll have the first half of a real JSON library in a folder on your computer — a tree of values that prints itself, and safe ways to read it back — built from nothing you haven't already learned.
Open the weather app on your phone. Before it can draw tomorrow's little cloud, it asks a server far away for the forecast. The answer comes back as text, something like {"city":"London","high":14,"rain":true}. Your messages arrive like that. So do game saves, maps and music charts. Every app you own leans on code that reads and writes that text. And here's the thing nobody tells you. The heart of that code is six small classes, of exactly the kind you already know how to write.
This chapter is like 0.7, where you built Wordle. The six chapters before it each taught you one thing. This one spends them. We build a library called json-java in two stages, and each stage runs before the next one starts. Chapter 1.8 then adds two more stages: reading JSON text, and turning your own classes into JSON.
Fair warning about three things. You'll call a method on an object, and nothing at all will happen to it. You'll print one object twice, a moment apart, and get two different answers. And two lines on your screen will both say exactly null, and mean completely different things.
What you'll be able to do
- Build a JSON value tree out of six small classes that each keep one promise.
- Say how one call,
ada.toJson(), prints a whole tree it knows nothing about. - Swap the insides of a class for Java's collections, and prove with tests that nothing outside noticed.
- Read a value back with
get("born", JsonNumber.class), and explain what the.classis for.
This chapter's src/ holds two folders, stage1 and stage2: the whole library at the end of each stage. They're two versions of the same classes, so they live apart. Every terminal block here starts with cd into one of them.
Inside a stage folder, javac *.java compiles every file at once. The * means "every name ending in .java". When all is well, it prints nothing.
$ cd stage1
$ javac *.javaSilence is good news. Then java JsonDemo runs a program, as always.
This chapter works like 0.7's Stage 3. I give you the shape of each method. The lines that actually decide the answer are left blank, and those are yours. Three blanks in Stage 1, four in Stage 2.
I'm not going to print those lines filled in anywhere on this page. That's deliberate. Reading a solution feels like learning. Writing one is learning.
You work in a copy of the stage folder, so src/ stays as the library, a known-good answer. cp -r stage1 my-stage1 makes that copy. cp is short for copy. The -r tells it to copy a folder and everything inside it. Chapter 1.8 uses the same command for its stages.
$ cp -r stage1 my-stage1
$ cd my-stage1
$ javac *.java
$ java JsonTests
52 passed, 0 failedJsonTests is your referee. It runs 52 small checks on the library and counts them. A passing check prints nothing. A failing one prints a FAIL line naming what it checked. So after every blank you fill in, compile and run it. 52 passed, 0 failed means your lines do what the library's do.
Stage 1A tree of values that prints itself
What you bring from 1.3 and 1.2:
- An interface is a list of promised methods, with no code behind them. A class that
implementsit keeps each promise its own way. - When you call a method, the label decides what you may ask. The object decides who answers.
- When one class needs another's code, holding it usually beats extending it.
- From 1.2:
finalfields,equalsandhashCodethat agree, andstatic.
Stage 1 comes in four steps. In 1a you meet the four simple values. 1b is an array that holds values, and 1c an object nobody can change. In 1d, one call prints the whole tree.
1a — Four simple values, one promise
The weather app's text is JSON. It's the most common way for programs to send each other data. It is only text, with only six kinds of value.
null— nothing here. The empty pocket, in text.trueandfalse.- A number, like
14or2.5. - A string, in double quotes, like
"London". - An array: a list of values in square brackets, like
[9,12,15]. - An object: named values in curly brackets, like
{"city":"London"}. Each name is a key, as in 1.2'sHashMap.
An array or an object can hold any value, including other arrays and objects. So JSON nests, like folders inside folders. A JSON document is really a tree.
The one promise
What can every kind of JSON value do? A number holds a number. An array holds a list. Nothing is shared — except one job. Every value can write itself out as JSON text. So that's the whole interface. Here's src/stage1/JsonValue.java, without its header comment.
public interface JsonValue {
String toJson();
}Line 1 is an interface called JsonValue. A library is meant for other people's code. So all of its classes are public: any code anywhere may use them. One rule comes with that word. A public class or interface must live in a file with exactly its name. So this one has to be JsonValue.java.
Line 3 is the promise: a method called toJson that hands back a String. No body.
Six classes keep that promise: JsonNull, JsonBoolean, JsonNumber, JsonString, JsonArray and JsonObject. Not one extends another. 1.3's box about abstract classes said to use a parent only when the children share code. These six share none. So an interface is the honest choice: one promise, six ways to keep it.
The simplest one: true and false
public final class JsonBoolean implements JsonValue {
private final boolean value;
public JsonBoolean(boolean value) {
this.value = value;
}
public boolean value() {
return value;
}
@Override
public String toJson() {
if (value) {
return "true";
}
return "false";
}Line 1 says this class keeps JsonValue's promise. It's also final, which gets its own subsection in a moment. Line 3 is the one field, a plain boolean, private final like 1.2's Money. Lines 5 to 7 fill it. Lines 9 to 11 are its getter, named after the field, the way 1.2 named getters.
Lines 13 to 19 keep the promise. @Override, from 1.3, asks javac to check this really is the promised method. True hands back the bare word true, false hands back false, with no quote marks. With quotes, it would be the string "true", a different JSON value.
The rest of the file is 1.2's work. An equals in Money's exact shape: instanceof, a cast, compare the field. It's written (other instanceof JsonBoolean) == false, which means the same as 0.3's !, spelled out so it can't be missed. A hashCode, using Boolean.hashCode(value). And a toString that hands back toJson(), so printing one shows JSON. Five of the six classes end the same way. The sixth is coming up.
final: nobody sneaks in a changeable one
1.3 showed you final on a class: it can have no children. You tried to extend Java's String and got cannot inherit from final String. All six value classes here are final too. src/stage1/Sneaky.java has the same attempt on JsonString, switched off.
// static class Loud extends JsonString { Loud() { super("x"); } } // take the // offIt's a one-line class that extends JsonString, and calls the parent's constructor with "x". Take the // off and compile.
$ cd stage1
$ javac Sneaky.java
Sneaky.java:9: error: cannot inherit from final JsonString
static class Loud extends JsonString { Loud() { super("x"); } } // take the // off
^
1 errorWhy it matters: without final, a child class could add a changeable field, and make toJson read it. A child goes anywhere its parent goes. So it would break every promise JsonString made. final closes that door. Put the // back.
JsonNull: exactly one, ever
Every JSON null is the same as every other. So why build more than one? src/stage1/JsonNull.java makes sure there's only ever one.
public final class JsonNull implements JsonValue {
public static final JsonNull INSTANCE = new JsonNull();
private JsonNull() {
}
@Override
public String toJson() {
return "null";
}
@Override
public String toString() {
return toJson();
}
}Line 3 is 1.2's static final shape: one box, belonging to the class, glued shut. It's called INSTANCE, in the capitals used for constants, and holds a ticket for a JsonNull built right there.
Lines 5 and 6 are the new trick: a private constructor. It's private, so only code inside JsonNull may call it, as line 3 does. Anyone else writing new JsonNull() gets a compile error. So line 3's is the only one that will ever exist. A class with exactly one object is called a singleton.
Lines 8 to 11 keep the promise: the four letters null. Lines 13 to 16 override toString, so printing it shows the same four letters.
And here's the sixth class from before: no equals. The starter one from 1.3's Object asks "same object?", the perfect test when only one exists.
Don't confuse the two nulls. Java's null is an empty pocket — no ticket at all. JsonNull.INSTANCE is a real object that stands for JSON's null. Stage 2 prints both, and they look identical.
JsonNumber: every number is a double
public JsonNumber(double value) {
if (Double.isNaN(value) || Double.isInfinite(value)) {
throw new IllegalArgumentException("JSON has no NaN or Infinity: " + value);
}
if (value == 0) {
this.value = 0.0; // turns -0.0 into plain 0.0
} else {
this.value = value;
}
}
@Override
public String toJson() {
boolean whole = value == Math.rint(value);
if (whole && Math.abs(value) < 1e18) {
return Long.toString((long) value);
}
return Double.toString(value);
}Every number is stored as a double. Lines 2 to 4 are a guard at the door, like Money's. Divide 0.0 by 0.0, and a double holds NaN, "not a number". Divide 1.0 by 0.0, and it holds Infinity. JSON has neither. Double.isNaN spots the first, Double.isInfinite the second, and the constructor refuses both.
Lines 5 to 9 store every zero as plain 0.0. The box below says why that matters.
Lines 12 to 19 write the number out. Line 14 asks whether it's whole. Math.rint(value) rounds to the nearest whole number, and if that changes nothing, it was whole. Line 16 then writes it as a long, so 1815 prints as 1815, not 1815.0. Line 18 writes everything else with Double.toString, like 2.5.
Going deeper Minus zero, the 1e18 limit, and what a double costs
A double can hold minus zero, -0.0. Both zeros pass value == 0. But Double.compare, which equals uses, and Double.hashCode both treat the two zeros as different. Storing one zero for both keeps 1.2's contract: equal things hash the same.
Line 15 checks Math.abs(value) < 1e18: the size, ignoring the sign, below 1 with 18 zeros. A long stops at about 9.2 × 10¹⁸, and past that the cast would quietly mangle the number.
And the cost of a double: it holds about 16 digits. A whole number above 2⁵³ can come back slightly different. Real libraries keep the number's original text, or use java.math.BigDecimal.
JsonString, and a quote inside quotes
How do you put a " inside a Java string, when " is what ends it? A backslash in front: "say \"hi\"". The backslash means "the next character is special". These are called escapes. \" is a quote mark. \\ is one real backslash. \n is a new line, and \t is a tab.
JSON strings use the same rules. So JsonString's toJson wraps the text in quotes and backslashes anything that needs it. A method called quote does that.
public static String quote(String text) {
StringBuilder out = new StringBuilder();
out.append('"');
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
switch (c) {
case '"':
out.append("\\\"");
break;
case '\n':
out.append("\\n");
break;
// ... and the same for \\, \r, \t, \b and \f ...
default:
out.append(c);
}
}
out.append('"');
return out.toString();
}Line 1 says quote takes text and hands back text. It's static — more on that below. Line 2 makes a StringBuilder, 0.6's whiteboard. Line 3 writes the opening quote mark, a single character. Lines 4 and 5 visit each character.
Lines 6 to 16 are a switch from 0.3, choosing by character. Lines 7 to 9: a quote mark becomes a backslash and a quote, which Java source spells "\\\"". Lines 10 to 12 turn a new line into the two characters \ and n. Line 13 stands for the other cases in the real file. Lines 14 and 15 write anything else as it is. Lines 18 and 19 write the closing quote and hand back the text. Then toJson() is one line: return quote(value);.
Why static? Because quote needs no JsonString, only the text in its brackets. And a JSON object's keys are quoted strings too, so JsonObject calls JsonString.quote(key) for each key. That's sharing without extends: one helper, no promises borrowed.
- JSON has six kinds of value: null, true/false, number, string, array, object. Arrays and objects can hold any of them, so JSON is a tree.
JsonValueis an interface with one promise,toJson(). Sixfinalclasses keep it, and none extends another.- A private constructor plus a
static finalfield gives you exactly one object:JsonNull.INSTANCE. - Code can be shared without
extends.JsonObjectborrowsJsonString.quote, a static helper.
1b — An array that has values
Four of the six kinds hold one simple thing each. The other two hold other values. That's where the tree comes from, and where polymorphism does its real work.
private final JsonValue[] items;
public JsonArray(JsonValue[] items) {
this.items = new JsonValue[items.length];
for (int i = 0; i < items.length; i++) {
if (items[i] == null) {
throw new IllegalArgumentException("item " + i + " is null; use JsonNull.INSTANCE");
}
this.items[i] = items[i];
}
}Line 1 is the field: an array of slots, each labelled JsonValue. So each slot can hold a ticket to any of the six kinds. A string can sit next to a number, next to another array.
Lines 3 to 11 are the constructor. Line 4 builds a fresh array the same length as the one handed in. Lines 5 to 10 copy each ticket across. That's 1.2's defensive copy, so the caller can't change our array later. Lines 6 to 8 add a guard. A Java null in a slot is an empty pocket. Calling toJson() on it would crash with a NullPointerException. So it's refused at the door, with a message saying what to use instead.
The file also has a constructor that takes nothing, new JsonArray(), which builds an empty array.
Writing the array out — the blank is yours
Now JsonArray's own toJson. The shape is given. The inside of the loop is yours.
@Override
public String toJson() {
StringBuilder out = new StringBuilder();
out.append('[');
for (int i = 0; i < items.length; i++) {
/* BLANK 1 — a comma between items, then this item written as JSON */
}
out.append(']');
return out.toString();
}Line 3 makes a StringBuilder, 0.6's whiteboard. Line 4 writes the opening [, in single quotes because it's one char. Line 5 visits each item. Lines 9 and 10 write the closing ] and hand back the finished text.
Blank one makes two decisions, and both matter.
The commas first. Picture [1,2,3]: three items, two commas. A comma goes before every item except one. Which one, and which value of i is it? An if around one append says it.
Then the item, and this is the line the whole library rests on. You don't know what kind of value sits in slot i. A string? A number? Another array? You don't need to know. What does every JsonValue promise it can do? Ask the item to do it, and append what it hands back. No if, no instanceof.
This blank sits inside a loop, so the shape compiles as it is. The referee prints FAIL lines instead. Later blanks sit inside an if (...) or after a return. A blank left as a comment there is not valid Java, so it won't compile. When the compiler shouts, it means a blank isn't filled in yet, not that you broke something.
Only after it runs: src/stage1/JsonArray.java is the library's version, blank filled. Checking your answer against it is fine. Reading it first is stealing from yourself.
Think about the item line you wrote. The slot is labelled JsonValue, so javac let you ask for toJson(). While the program runs, each item answers its own way. And if the item is another JsonArray, its own toJson runs on that inner array. That's 0.4's recursion. The simple values never call anyone, so they're the base cases, however deep the tree.
Notice what JsonArray isn't. It isn't a kind of string, or a kind of number. It extends nothing. It has values, and asks each one to do its job. That's 1.3's composition, and the whole library is built on it.
1c — An object nobody can change
A JSON object is pairs: a key and a value, a key and a value. This first version keeps them in two arrays side by side. keys[0] names values[0], keys[1] names values[1], and so on.
JsonObject can never change, like Money. So how do you add a pair? The way 1.2's plus did. Build a new object with one more pair, and hand it back. That's the method with, and its copying is yours.
private final String[] keys;
private final JsonValue[] values;
public JsonObject with(String key, JsonValue value) {
// ... two null checks, unchanged ...
int at = indexOf(key);
int size = keys.length;
if (at < 0) {
at = size;
size = size + 1;
}
String[] newKeys = new String[size];
JsonValue[] newValues = new JsonValue[size];
for (int i = 0; i < keys.length; i++) {
/* BLANK 2 — copy pair i into the new arrays */
}
/* BLANK 3 — put the new key and value in slot at */
return new JsonObject(newKeys, newValues);
}Lines 1 and 2 are the two side-by-side arrays. Line 4 says with takes a key and a value and hands back a JsonObject — a new one. Line 5 stands for two guards in the real file: no missing key, and no Java null as a value. Leave those as they are.
Line 6 asks a small private method, indexOf, whether this key is already here. It hands back the key's slot, or −1 if it isn't there. Lines 7 to 11 decide the new size. A new key goes in a fresh slot at the end, so the arrays grow by one. An existing key keeps its slot. Lines 12 and 13 build two fresh arrays of that size.
Blank two is two short lines, one per array. Slot i of the old keys goes into slot i of the new keys. The same for the values.
Blank three is two more lines, and the slot is at, not i. For a new key, at is the fresh slot at the end. For an existing key, it's the old slot, so the new value lands on top of the old one.
Line 18 builds the new object from the new arrays. It calls a second, private constructor that keeps the arrays as they are. Only with may call it. Those arrays were built a moment ago, so nobody else holds a ticket to them, and no copy is needed.
Only after it runs: src/stage1/JsonObject.java is the library's version, blanks filled.
Look at what your four lines never do. None of them writes into keys or values. They only read the old arrays and write the new ones. So the old object is never touched, and that's the whole of immutability, done by hand.
Calling with() again and again
Because with hands back a JsonObject, you can call with on what it hands back, straight away. That's how src/stage1/JsonDemo.java builds things.
JsonObject address = new JsonObject()
.with("city", new JsonString("London"))
.with("postcode", JsonNull.INSTANCE);It's one statement over three lines, read left to right. new JsonObject() builds an empty object. .with("city", …) is called on that, and hands back a new object with one pair. The second .with(...) is called on that one, and hands back an object with two pairs. That last one goes into address.
This is method chaining. The line breaks don't matter, because the statement only ends at the ;. The objects in the middle were stepping stones. Nothing points at them afterwards, so Java is free to reclaim them later.
Now the trap you can probably smell already. src/stage1/WithIgnored.java:
JsonObject a = new JsonObject().with("a", new JsonNumber(1));
a.with("b", new JsonNumber(2));
System.out.println("A: " + a);
a = a.with("b", new JsonNumber(2));
System.out.println("B: " + a);Line 1 builds an object with one pair, "a" → 1, and puts it in a. Line 3, a.with("b", new JsonNumber(2));, adds "b" → 2, on a line of its own. Line 4 prints a. Line 6, a = a.with("b", new JsonNumber(2));, does the same with, but this time puts what comes back into a. Line 7 prints a again.
Two lines, A and B. Does line 3's a.with(...) add "b" to a?
$ cd stage1
$ java WithIgnored
A: {"a":1}
B: {"a":1,"b":2}A has no "b". Line 3 built a brand new object with both pairs, and handed it back. Nobody caught it. So it went nowhere, and a still holds the ticket to the old one-pair object.
If you saw it coming, that's 0.6 paying off. It's s.toUpperCase(); on a line by itself, and 1.2's a.plus(b);. Immutable things change by handing you a new one. Catch what comes back, as line 6 does.
Equal, but printed differently
In JSON, an object's pairs have no official order. {"a":1,"b":2} and {"b":2,"a":1} mean the same thing. So JsonObject's equals checks for the same keys with equal values, in any order. Its hashCode adds up one number per pair, which gives the same total in any order. 1.2's contract, kept.
But toJson writes the pairs in the order you added them, because people reading the text expect that. src/stage1/KeyOrder.java builds the same two pairs both ways round.
JsonObject ab = new JsonObject().with("a", new JsonNumber(1)).with("b", new JsonNumber(2));
JsonObject ba = new JsonObject().with("b", new JsonNumber(2)).with("a", new JsonNumber(1));
System.out.println("C: " + ab + " " + ba);
System.out.println("D: equals? " + ab.equals(ba));
System.out.println("E: same text? " + ab.toJson().equals(ba.toJson()));Line 1 adds "a" then "b". Line 2 adds "b" then "a". Line 4 prints both. Line 5, ab.equals(ba), asks whether the two objects are equal. Line 6, ab.toJson().equals(ba.toJson()), asks whether their JSON texts are equal. That's String's equals, comparing letters.
Three lines. What does C look like? Then D and E: can two objects be equal while their text isn't?
$ cd stage1
$ java KeyOrder
C: {"a":1,"b":2} {"b":2,"a":1}
D: equals? true
E: same text? falseEqual objects, different text, and both answers are right. The objects mean the same JSON. The texts have their letters in a different order.
The lesson is a sharp one. Never compare JSON by comparing its text. Two programs can send you the same data, written in a different order. Compare the values. And remember this file. You'll run it again, unchanged, after Stage 2 rips out the insides of JsonObject.
Two more methods you'll use. has(key) answers true or false: is that key here? get(key) hands back its value, as a JsonValue, because that's all the label can promise. For a missing key, get hands back Java's null. For a key whose value is JSON null, you get JsonNull.INSTANCE, a real object. "No such key" and "the key says null" are different answers, on purpose.
Two side-by-side arrays are simple, and slow at scale. Every with copies every pair, and every get walks the keys one by one. Fine for a few dozen pairs. Stage 2 swaps the arrays for one of Java's ready-made maps.
JsonArrayandJsonObjecthave values. They don't extend anything. That's composition, and the tree is built from it.- One line in
JsonArray.toJsoncan run any of six methods — including its own, on an inner array. The simple values are the base cases. withhands back a newJsonObject. Catch what it returns, or nothing changes.- Two JSON objects can be equal while their text differs. Compare values, never text.
1d — One call prints the whole tree
Time to put it together. src/stage1/SmallTree.java builds a small tree and prints it with one call.
JsonValue[] languages = { new JsonString("English"), new JsonString("French") };
JsonObject ada = new JsonObject()
.with("born", new JsonNumber(1815))
.with("languages", new JsonArray(languages));
System.out.println(ada.toJson());Line 1 makes a plain Java array with two JsonStrings in it. Lines 3 to 5 chain two withs. One adds a number under "born". The other, on line 5, adds new JsonArray(languages) under "languages", built from line 1's array. Line 7 makes one call, ada.toJson(), and prints what comes back.
$ cd stage1
$ java SmallTree
{"born":1815,"languages":["English","French"]}One call wrote out four objects' worth of JSON. Here's how, using 0.4's stack of plates. Watch which toJson is on top each time, and why.
Call stack — top plate first
Built so far
Step 1 — ada.toJson(). Java follows ada's ticket, and the note on the object says JsonObject. So JsonObject's toJson gets a plate. It writes {. Then, for the first key, it calls JsonString.quote("born") and adds a :. That's the static helper, so there's no choosing to do. There's only one quote.
Call stack — top plate first
Built so far
Step 2 — values[0].toJson(). The slot is labelled JsonValue. The note on the object says JsonNumber. So JsonNumber's version gets the next plate. It's a whole number, so it hands back 1815, and its plate comes off. A base case — it called nobody.
Call stack — top plate first
Built so far
Step 3 — values[1].toJson(). The object writes a comma and its second key. Then the same line of code that just ran JsonNumber's version runs again. This time the note says JsonArray. So a different method gets the plate. It starts its own StringBuilder, and writes [.
Call stack — top plate first
Built so far
Step 4 — items[0].toJson(). Four plates high now. The array asks its first item, and the note says JsonString. Its toJson calls quote, and hands back "English", quote marks included. Base case. Then the same again for "French", after a comma.
Call stack — top plate first
Built so far
Step 5 — the array returns. It writes ] and hands its whole text, ["English","French"], back down the stack. Its plate comes off. The object adds that text to its own whiteboard.
Call stack — top plate first
Built so far
Step 6 — the object returns. It writes } and hands the finished text to main, which prints it. Count the calls: one from main, five toJsons in all, four different classes. Nobody anywhere asked "which kind of value is this?".
That's Stage 1 in one picture. Each value only knows how to write itself. It doesn't know what kind its children are. It asks them, and each question goes to the right object's method. However big the tree, one call is all it takes.
The full demo
src/stage1/JsonDemo.java builds a bigger tree, about the mathematician Ada Lovelace, then does one more thing. Here's its second half.
// One loop, six kinds of value. Each one answers toJson() its own way.
JsonValue[] mixed = {
JsonNull.INSTANCE,
new JsonBoolean(true),
new JsonNumber(2.5),
new JsonString("tab\there"),
new JsonArray(),
new JsonObject()
};
for (JsonValue v : mixed) {
System.out.println(v.toJson());
}Lines 2 to 9 make an array with one of each kind of value. Line 6's \t puts a real tab between "tab" and "here". Lines 7 and 8 are an empty array and an empty object. Lines 10 to 12 are 1.3's one-call loop: one toJson, printed, for each.
The first half builds Ada with five keys: "name", "born", "languages", "address" and "note". The note is the Java text "She wrote \"the first program\".". It prints ada.toJson(), then an empty line.
The loop prints six lines. Write all six. Two are worth care. What does the tab line look like? And in Ada's line at the top, how does the "note" come out? What happens to the quote marks inside it?
$ cd stage1
$ java JsonDemo
{"name":"Ada Lovelace","born":1815,"languages":["English","French"],"address":{"city":"London","postcode":null},"note":"She wrote \"the first program\"."}
null
true
2.5
"tab\there"
[]
{}The tab came out as a backslash and a t. The Java text held a real, invisible tab. quote turned it back into the two-character escape, because JSON doesn't allow a raw tab inside a string. If you expected a gap, you were thinking like Java's println, not like JSON.
The note kept its backslashes. The quote marks inside her sentence came out as \". Otherwise a program reading it back would think the string ended at "wrote". And the address sits nested inside Ada, its null written plainly by JsonNull.INSTANCE.
Do we really need toString?
Every value class has a toString that hands back toJson(). Is it really needed? src/stage1/NoToString.java makes a home-made JsonValue that keeps the promise and nothing more.
static class Bare implements JsonValue {
@Override
public String toJson() {
return "\"bare\"";
}
}
public static void main(String[] args) {
JsonValue n = new JsonNumber(1815);
JsonValue b = new Bare();
JsonValue[] both = { n, b };
System.out.println("F: " + n);
System.out.println("G: " + b.toJson());
System.out.println("H: " + b);
System.out.println("I: " + new JsonArray(both));
}Lines 1 to 6 are a tiny class that implements JsonValue. Its toJson hands back "bare", quote marks included. It has no toString. The static in front lets it live inside another class's file, as 1.2 showed.
Lines 9 to 11 make a JsonNumber, a Bare, and an array holding both. Line 13 glues the number onto text, so its toString gets called. Line 14 calls Bare's toJson by name. Line 15 glues Bare onto text. Line 16 builds a JsonArray from both and glues that onto text.
Four lines, F to I. The pair to bet on is H and I. Bare has no toString. What does H print? And I puts that same object inside an array — what does the array print for it?
$ cd stage1
$ java NoToString
F: 1815
G: "bare"
H: NoToString$Bare@8bcc55f
I: [1815,"bare"]H and I printed the same object two different ways, a moment apart.
Case H glued Bare onto text, so Java called toString. Bare has none. So Java went up the family tree to Object, and ran 1.3's starter version: class name, @, number. The $ shows that Bare lives inside NoToString.
Case I asked JsonArray for its text, and JsonArray never calls toString on its items. It calls toJson(), the promised method, so out came "bare".
Two calls on one object, two different methods, each picked by the object while the program ran. That's why all six value classes, JsonArray included, add a one-line toString. The library never needs it, because it only ever calls toJson(). You need it, every time you println.
Did all of it work?
You've run the referee after every blank. Run it once more, on the library's own Stage 1 and on your my-stage1 copy. The two should agree.
$ cd stage1
$ java JsonTests
52 passed, 0 failedTwo of the 52 are worth knowing. new JsonNumber(0.1 + 0.2) writes out as 0.30000000000000004, 0.2's double lie, faithfully reported. And two objects are equal while their texts differ. That's KeyOrder, kept as a test so nobody "fixes" it.
Six classes, one promise, a tree that prints itself, and three deciding blanks that are yours. That's the shape real JSON libraries use. Everything from here on changes the insides, never that shape.
- One call,
ada.toJson(), prints the whole tree. Each value writes itself and asks its children to write themselves. - A different
toJsonruns at every level, picked by each object. Nothing anywhere asks "which kind is this?". printlnand+calltoString.JsonArraycallstoJson. A class without its owntoStringgetsObject's.
Stage 2Real collections, and asking for the kind you want
What you bring from 1.4:
- Angle brackets are a blank on a type's label.
List<Circle>is a listjavacwill only let circles into. - The compiler checks those brackets, then throws them away. That's erasure.
List<? extends Shape>reads "a list of some kind of Shape", for a list you only read from.
Stage 1 ended with an honest complaint: with copies arrays by hand, and get walks every key. Java's collections do that work for you. Stage 2 is the same library. Exactly two files change inside, JsonArray.java and JsonObject.java, plus a bigger demo and more tests.
It comes in three steps. 2a swaps the arrays for real collections. 2b asks for exactly the kind of value you want. 2c tells two kinds of nothing apart.
2a — Swap the arrays for collections
One tool at a time. JsonArray will use 1.4's List and ArrayList, a row that grows by itself. That's Stage 1's copying loop, written once by somebody else.
JsonObject needs something new: a Map. You've got one on your phone already.
Your phone's contacts are a map. You look up a name, and get back a number. Each name appears once. Saving a new number under an old name replaces the old number.
key (name) value (number)
"Mum" → "0161 496 0001"
"Sam" → "0161 496 0002"
"Dentist" → "0161 496 0003"A Map is the promise behind that: put a value under a key, and get it back by the key. It has two blanks, like 1.4's Pair: Map<K, V>, one for keys and one for values. 1.2's HashMap keeps that promise. A JSON object is exactly this shape, with String keys and JsonValue values.
A LinkedHashMap is a Map that remembers the order you added things. A plain HashMap promises no order at all. Stage 1 printed keys in the order you added them, so the library keeps that order.
A Set is the names alone, with no repeats: the left-hand column of the picture. A map's keySet() hands you its keys as one.
None of these are loaded unless a file asks. So JsonObject.java now starts with four import lines.
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;java.util is the part of Java's toolbox where the collections live. One name is new. Collections, with an s, holds static helpers for lists and sets, like Math does for numbers. Two of them turn up in 2c. JsonArray.java imports ArrayList, Collections and List the same way.
JsonArray, with a List inside
private final List<JsonValue> items;
public JsonArray() {
this.items = new ArrayList<>();
}
public JsonArray(JsonValue[] items) {
this.items = new ArrayList<>();
for (int i = 0; i < items.length; i++) {
add(i, items[i]);
}
}
private void add(int index, JsonValue item) {
if (item == null) {
throw new IllegalArgumentException("item " + index + " is null; use JsonNull.INSTANCE");
}
items.add(item);
}Line 1 is the field. It used to say JsonValue[] items. Now it's a List<JsonValue>. The label says List, the promise, not ArrayList, because code that uses items only needs the promise.
Lines 3 to 5 build an empty ArrayList. The empty <>, the diamond, copies JsonValue in from the field's label.
Lines 7 to 12 are Stage 1's array constructor, kept so every Stage 1 program still compiles. It hands each item, with its position, to a helper called add.
Lines 14 to 19 are that helper. It's private, so only JsonArray can call it. Lines 15 to 17 are Stage 1's guard, unchanged. Line 18, items.add(item);, adds it to the end of our own list. Change your array later, and our list won't notice: still 1.2's defensive copy.
get, size and toJson now ask the list instead of an array. Not one promise changed.
JsonObject, with a LinkedHashMap inside
private final LinkedHashMap<String, JsonValue> members;
public JsonObject(Map<String, ? extends JsonValue> members) {
this.members = new LinkedHashMap<>();
for (String key : members.keySet()) {
JsonValue value = members.get(key);
checkPair(key, value);
this.members.put(key, value);
}
}Line 1 is the whole of what used to be two arrays. The keys are Strings and the values are any JsonValue.
Lines 3 to 10 are a new constructor that copies any map. Line 3 reads Map<String, ? extends JsonValue>: String keys, and values of some kind of JsonValue. That's 1.4's wildcard, safe because the constructor only reads. The parameter shares the field's name, so this.members means the field, as in 1.1.
Line 4 gives the new object its own empty map. Line 5 loops over members.keySet(), the map's keys. Line 6 fetches each key's value. Line 7 runs Stage 1's null checks, moved into a private method called checkPair. Line 8 puts the pair into our own map: a defensive copy.
Now with. Stage 1's copying took two blanks. This one is three lines, and two are yours.
public JsonObject with(String key, JsonValue value) {
checkPair(key, value);
JsonObject copy = /* BLANK 4 — a new object holding every pair of this one */;
/* BLANK 5 — the new pair goes into the copy */
return copy;
}Line 2 checks the pair. Line 5 hands back the copy. Blank four builds the copy. You've just read a constructor that copies any map. Which map holds this object's pairs?
Blank five is one put, and the whole decision is whose map. Put the pair into members and you've changed this object, the one with promised never to touch. You want the copy's map. Inside JsonObject's own code, you may reach another one's private field with a dot. 1.2's equals did it with that.value.
Only after it runs: src/stage2/JsonObject.java is the library's version, blanks filled.
Stage 1's private indexOf loop is gone. A map finds a key by its hash code, 1.2's pigeonholes, so members.get(key) needs no loop.
Going deeper Stage 2 still copies on every with()
Blank four still copies every pair, so building a big object one with at a time is still slow. Stage 2 fixed the lookup: get and has(key) no longer walk the keys. The copying is the price of immutability. Real libraries often hand you a changeable "builder" to fill, then freeze it once, as StringBuilder and String do.
Equal — still?
equals got shorter. JsonObject's now ends with this.members.equals(that.members), handing the question to the map. JsonArray's hands it to the list, which compares item by item, in order. Each hashCode asks the list or map too.
Here's where you get to bet. src/stage2/KeyOrder.java is Stage 1's file, copied across unchanged.
A LinkedHashMap remembers the order keys went in. JsonObject.equals now hands the question to the two maps. So with the new insides, does D still say true? Write all three lines.
$ cd stage2
$ java KeyOrder
C: {"a":1,"b":2} {"b":2,"a":1}
D: equals? true
E: same text? falseExactly what Stage 1 printed. Remembering the order and caring about it are different things. A LinkedHashMap remembers order so you can walk its keys in it. That's why C still prints each object the way it was built. But every Map's equals follows one rule: same keys, equal values, any order. JSON's rule, too.
If you said false, you guessed the way a careful person guesses. It's exactly the quiet change a rewrite can bring in, and why Stage 1's tests exist. You don't trust your guess. You run the tests.
The test file has Stage 1's 52 checks in it, unchanged, plus 17 new ones for this stage.
$ cd stage2
$ java JsonTests
69 passed, 0 failedFifty-two old checks passing on brand new insides: that's the proof the promises didn't change.
A list of some kind of JsonValue
One more constructor joined JsonArray. src/stage2/JsonDemo.java builds Ada's languages as a list.
List<JsonString> languages = new ArrayList<>();
languages.add(new JsonString("English"));
languages.add(new JsonString("French"));Line 1 makes a List<JsonString>, because every language is text. Lines 2 and 3 add two. Later the demo hands it to new JsonArray(languages).
1.4 showed that a List<Circle> doesn't count as a List<Shape>. So what if JsonArray's list constructor asked for a List<JsonValue>? Try it. In stage2/JsonArray.java, change the constructor's List<? extends JsonValue> items to List<JsonValue> items. Then compile the demo.
$ cd stage2
$ javac JsonDemo.java
JsonDemo.java:26: error: no suitable constructor found for JsonArray(List<JsonString>)
.with("languages", new JsonArray(languages))
^
constructor JsonArray.JsonArray(JsonValue[]) is not applicable
(argument mismatch; List<JsonString> cannot be converted to JsonValue[])
constructor JsonArray.JsonArray(List<JsonValue>) is not applicable
(argument mismatch; List<JsonString> cannot be converted to List<JsonValue>)
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 errorno suitable constructor found: javac tried every constructor, and lists why each didn't fit. A constructor asking for List<JsonValue> would be useless, because nobody could hand it a list of strings. So the real one takes List<? extends JsonValue> items, and only reads from it. Change it back before you go on.
JsonArraynow holds aList<JsonValue>.JsonObjectholds aLinkedHashMap<String, JsonValue>, a map that remembers key order.- Remembering order isn't caring about it.
Map.equalsignores order, so objects compare exactly as in Stage 1. - The new constructors take
List<? extends JsonValue>andMap<String, ? extends JsonValue>, so aList<JsonString>fits.
2b — Ask for exactly the kind you want
Now the library has to hand things back nicely. ada.get("name") hands back a JsonValue, because a key could hold any kind. You want the text. Stage 1 made you write this.
String name = ((JsonString) ada.get("name")).value();(JsonString) casts the JsonValue: "trust me, it's a string". The outer brackets let you call .value() on the result. If "name" ever holds a number, the cast lies, and the program stops with a message about classes.
What you bring from 1.4 and 1.6:
- A getter can't check
instanceof T. That doesn't compile, becauseTis erased before the program runs. - So 1.4's shelf of shapes asked for the class itself:
shelf.get(0, Circle.class). Asked wrong, it saidshape 1 is a Square, not a Circle. Circle.classis a real object while the program runs, the Class object 1.6 opened up. It's the piece ofTthat survives erasure.
The JSON tree's typed getters are the very same move, made on JsonObject. So the two lines that do the checking are yours.
public <T extends JsonValue> T get(String key, Class<T> type) {
JsonValue value = members.get(key);
if (value == null) {
throw new IllegalArgumentException("no key \"" + key + "\" in " + toJson());
}
if (/* BLANK 6 — this value is NOT the kind the token names */) {
throw new ClassCastException("\"" + key + "\" is a " + value.getClass().getSimpleName()
+ " (" + value.toJson() + "), not a " + type.getSimpleName());
}
return /* BLANK 7 — the value, as a T */;
}Line 1 reads, one piece at a time. <T extends JsonValue> gives the method its own blank, T. The bound says T must be some kind of JsonValue. T get(...) says it hands back a T. And Class<T> type ties the token to that blank. Hand over JsonString.class, and T is JsonString, so you get a JsonString back.
Line 2 looks the key up. Lines 3 to 5 refuse a missing key loudly, with an IllegalArgumentException whose message prints the whole object.
Lines 7 and 8 are the refusal for the wrong kind: a ClassCastException with a message about your data. getClass().getSimpleName() asks an object for its class's short name, like JsonNumber, as the shelf's message did. type.getSimpleName() does the same for the token.
Blank six is the shelf's question, asked of a JSON value. The token can answer "is this object one of mine?" while the program runs, which instanceof T never could. You want the opposite answer, so remember this library's way of writing "not": == false.
Blank seven hands the value back as a T. Don't write (T) value. After erasure that cast checks nothing, as 1.4 showed. Ask the token to do the cast instead.
Only after it runs: src/stage2/JsonObject.java is the library's version, blanks filled.
What you just used is the token's two methods. One is instanceof, asked of the class object you're holding instead of a name written in the code. The other is a cast done by that object, so it really checks, unlike (T). Blank six already made sure it will pass.
src/stage2/AskWrongKind.java makes the mistake on purpose. It builds {"born":1815}, then asks for the number as text, with ada.getString("born"). (getString is a one-line wrapper round this get. You'll see it in a moment.)
In 1.4, the sloppy getter with no token let the wrong kind out. The crash came later, back in main. With the token, where does this one stop? Which method is on the top at line? (From 0.4's stack traces: the top line is where it stopped.) And what does the message say?
$ cd stage2
$ java AskWrongKind
Exception in thread "main" java.lang.ClassCastException: "born" is a JsonNumber (1815), not a JsonString
at JsonObject.get(JsonObject.java:72)
at JsonObject.getString(JsonObject.java:78)
at AskWrongKind.main(AskWrongKind.java:15)The top plate is JsonObject.get, at line 72 of JsonObject.java: the throw under your blank six. The mistake was caught inside the getter, before a wrong value could be handed back. The token made that possible.
And the message is about your data: the key, the kind it found, the value, and the kind you wanted. Read the trace downwards, the 0.4 way. It walks you to getString, called from your main, at line 15 of AskWrongKind.java.
What the bound buys
The bound, extends JsonValue, does one more job, and it does it before the program runs. AskWrongKind.java has a line switched off that asks for a kind that isn't JSON at all: ada.get("born", String.class). Take the // off and compile.
$ cd stage2
$ javac AskWrongKind.java
AskWrongKind.java:13: error: method get in class JsonObject cannot be applied to given types;
String text = ada.get("born", String.class); // take the // off and compile
^
required: String,Class<T>
found: String,Class<String>
reason: inference variable T has incompatible bounds
equality constraints: String
lower bounds: JsonValue
where T is a type-variable:
T extends JsonValue declared in method <T>get(String,Class<T>)
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 errorA long message, so read two lines. found: String,Class<String> is what you handed over. The last-but-two line, T extends JsonValue, is the rule you broke. A JSON value can never be a Java String. So asking is always a mistake, and javac says so before anything runs. Put the // back.
The one-line getters
With the generic get written once, the everyday getters are one line each.
public String getString(String key) {
return get(key, JsonString.class).value();
}
public JsonObject getObject(String key) {
return get(key, JsonObject.class);
}Lines 1 to 3: getString asks your generic get for a JsonString. Then it hands back .value(), the plain String. Lines 5 to 7 hand back a whole nested object, for reading deeper into the tree. getNumber, getBoolean and getArray follow the same pattern. JsonArray has a get(index, type) that works like the generic get, by position instead of by key.
So reading the tree back needs no casts at all. Here's the end of src/stage2/JsonDemo.java.
String name = ada.getString("name");
double born = ada.getNumber("born");
String city = ada.getObject("address").getString("city");
JsonString first = ada.getArray("languages").get(0, JsonString.class);
System.out.println(name + " was born in " + (int) born + ".");
System.out.println("She lived in " + city + ".");
System.out.println("First language: " + first.value());
for (String key : ada.keys()) {
System.out.println(" key: " + key);
}Lines 1 and 2 read the name as a String and the year as a double. Line 3 reads the address object, then the city inside it, as one chain. Line 4 uses the token directly. Lines 6 to 8 print them, with 0.2's (int) born so the year has no .0. Lines 10 to 12 loop over keys(), in the order they were added.
$ cd stage2
$ java JsonDemo
{"name":"Ada Lovelace","born":1815,"languages":["English","French"],"address":{"city":"London","postcode":null},"note":"She wrote \"the first program\"."}
Ada Lovelace was born in 1815.
She lived in London.
First language: English
key: name
key: born
key: languages
key: address
key: noteStage 1's line, letter for letter, then the reading back, with not one cast in sight.
2c — Two kinds of nothing
Remember the two nulls? Ada's address has a "postcode" holding JSON null, and no "age" key at all. src/stage2/TwoNulls.java pokes both.
JsonObject address = new JsonObject()
.with("city", new JsonString("London"))
.with("postcode", JsonNull.INSTANCE);
System.out.println("A: " + address.get("postcode"));
System.out.println("B: " + address.get("age"));
try {
System.out.println("C: " + address.getString("postcode"));
} catch (RuntimeException e) {
System.out.println("C refused: " + e);
}
try {
System.out.println("D: " + address.getString("age"));
} catch (RuntimeException e) {
System.out.println("D refused: " + e);
}Lines 1 to 3 build the address. Lines 5 and 6 print the plain get of each key. Lines 8 to 12 try getString("postcode") with 1.5's try and catch. Line 9 prints what it hands back. If it throws, catch (RuntimeException e) catches any unchecked exception, and line 11 prints it. Lines 13 to 17 do the same for "age".
Four lines. What do A and B print — and if they look alike, are they the same thing? Then C and D: does getString hand back Java's null, the text "null", or refuse? With which exception?
$ cd stage2
$ java TwoNulls
A: null
B: null
C refused: java.lang.ClassCastException: "postcode" is a JsonNull (null), not a JsonString
D refused: java.lang.IllegalArgumentException: no key "age" in {"city":"London","postcode":null}A and B look identical, and they're nothing alike. A is JsonNull.INSTANCE, a real object, printing itself. B is Java's null, an empty pocket, which + writes as the word null.
C and D tell them apart. The postcode is there, but it's the wrong kind: ClassCastException. The age isn't there at all: IllegalArgumentException. Neither hands you a quiet null to trip over later.
JsonArray.items() hands back the array's items as a List, for a for-each loop. JsonObject.keys() does the same with the keys, as a Set.
public List<JsonValue> items() {
return Collections.unmodifiableList(items);
}Line 2 doesn't hand out the real list. Collections.unmodifiableList wraps it in a view, a window you can read through but not add through. keys() uses unmodifiableSet the same way. But the label is still plain List<JsonValue>, which has an add method, so array.items().add(...) compiles. src/stage2/ReadOnlyItems.java tries it.
$ cd stage2
$ java ReadOnlyItems
size before: 0
Exception in thread "main" java.lang.UnsupportedOperationException
at java.base/java.util.Collections$UnmodifiableCollection.add(Collections.java:1068)
at ReadOnlyItems.main(ReadOnlyItems.java:12)An UnsupportedOperationException is Java's stop for "this object doesn't do that". It comes while the program runs, because no type means "a List you can't add to".
get("born", JsonNumber.class)hands over a type token: the class object, which survives erasure.type.isInstanceandtype.castreally check.- So the wrong kind is caught inside the getter, with a message about your data, not later at the caller.
- The bound
<T extends JsonValue>makesjavacrefuseString.classbefore anything runs. - Missing key:
IllegalArgumentException. Wrong kind, JSON null included:ClassCastException. Never a quietnull.
Section 3Make it yours
Ideas, not instructions, with no step-by-step answers, on purpose. Work in your my-stage2 copy. Pick the one that appeals to you.
- A JSON object about you. Write a
Me.javawhosemainbuilds your name, your age and an array of three favourite things. Print it with one call. Then read your age back withgetNumber. - Pretty printing. Add a second promise to
JsonValue,String toJson(int indent). Compile, andjavacnames every class that hasn't kept it yet. - Count the values. Count every value in a tree, nested ones included. Arrays and objects add up their children. It's the same recursion as
toJson.
Every tick below is a program that runs. Predict on paper before every run.
Section 4Check yourself
With Stage 1's library, you write JsonValue v = new JsonNumber(2.5); and then double d = v.value();. JsonNumber really does have a value() method. What happens?
Judgment call. You want a JsonDate value that writes a date as a JSON string, like "2026-10-13". A teammate suggests JsonDate extends JsonString, since it's written as a string anyway. What's your answer?
Judgment call. A teammate rewrites getString so that a missing key hands back Java's null instead of throwing. "Friendlier," they say. What do you say?
Section 5What broke, and what surprised you
Twenty-odd files is plenty of room to go wrong. Before you close this page, write down — actually write — the answers to these:
- Which predictions did you get wrong? For each one, what were you picturing instead?
- Which of the seven blanks did you get wrong first time? Blank three, with
atinstead ofi, and blank five, with the wrong map, are the popular ones. - Which
FAILline did the referee print most, and how did its name point you at your mistake? - In
NoToString, why did one object print two ways? Say it without the word "polymorphism" — then with it. - Did you bet that
KeyOrderwould change after Stage 2? What made you think so? - In your own words: why does
getneedJsonString.class, when the box on the left already saysJsonString?
If a run didn't match what this page claims, trust your terminal and go find out why. Every output here came from a real run on JDK 17. But your machine is the one that counts.
Section 6Everything you just used — and what 1.8 adds
You learned almost nothing new today, and wrote the lines that matter yourself. Look at what the two stages spent:
| From | What it was | Where it turned up |
|---|---|---|
| 1.1 | Tickets, this, Java's null | Every slot in JsonArray, the parameter called members, the empty pocket get hands back |
| 1.2 | final, immutability, defensive copies, equals/hashCode, static | Six immutable classes, with, every constructor's copy, JsonNull.INSTANCE, JsonString.quote |
| 1.3 | Interfaces, one call running many methods, composition, final classes | JsonValue, the one line in JsonArray.toJson that runs six methods, an array that has values, Sneaky.java |
| 1.4 | Generics, erasure, wildcards, the class token | List<JsonValue>, LinkedHashMap, List<? extends JsonValue>, get(key, JsonString.class) |
| 1.5 | Exceptions, try/catch | Every refusal in the library, and TwoNulls |
| 1.6 | Asking an object about its class | getClass().getSimpleName() in every error message |
Six chapters of small ideas add up to the core of a real library.
Now the honest part
Open stage2/JsonDemo.java and count the lines that build Ada: twelve lines of Java, for one line of JSON. And the weather app never calls with at all. Its JSON arrives as text, from a server.
String reply = "{\"city\":\"London\",\"high\":14,\"rain\":true}";That's a String, not a JsonObject. You can't call getNumber("high") on it, and nothing in your library can turn those letters into a tree yet. Some text that arrives will be broken, too. Something has to notice, and say where.
The other direction aches too. To send a Person of your own as JSON, you'd write a with chain by hand, for every class you ever make.
Chapter 1.8 fixes both, in two more stages. Stage 3 reads JSON text and builds the tree. That's where 1.5's exceptions get their real job: saying exactly where broken text went wrong. Stage 4 turns your own classes into JSON and back, walking their fields with 1.6's reflection. You already have every idea it needs, and now the tree to hang them on. See you there.
Nice. Chapter marked complete — and you've got half a library to show for it.