Somewhere around hour thirty of ZeroDepsHack 2026, we were staring at a
parseUnicodeEscape method, arguing about whether a lone high surrogate
followed by a regular character should throw at parse time or survive until
serialization. The answer, per RFC 8259, was unambiguous — reject it — but the
fact that we were even having the conversation said something about how much
JSON parsing we'd been outsourcing to Jackson for years without thinking.
That was the question the hackathon was built around. Not "are dependencies
bad?" — they obviously aren't. But the supply chain keeps catching fire:
left-pad breaking half the JS ecosystem over 11 lines in 2016, the chalk
maintainer getting phished in 2025, npm's Shai-Hulud worm self-replicating
through hundreds of packages, AI coding tools hallucinating package names that
attackers pre-register. Jackson and Gson aren't the problem. The question is
whether we still understand what's inside the box we'd normally just import.
We decided to find out by building JValue — a zero-third-party-dependency
JSON toolkit for Java 25 — during the Aug 28–31 build window, for Track B:
Parsers & Data Formats.
What We Reimplemented
JValue shipped as a hand-written recursive-descent JSON parser, serializer,
RFC 6901 JSON Pointer implementation, file convenience API, and CLI — 14
production source files, roughly 3000 lines of Java, all compiled with javac
and depending on nothing beyond java.base. No Maven. No Gradle. No runtime
dependency jar anywhere. We verified this at submission with jdeps, which
confirmed every production class depended only on java.base.
The public API surface we ended up with fit in a few lines:
JsonValue doc = Json.parse(jsonString);
String compact = Json.stringify(doc);
String pretty = Json.stringifyPretty(doc);
String name = Json.pointer(doc, "/users/0/name").asString();
Json.writePrettyFile(doc, Path.of("out.json"));
JsonValue back = Json.read(Path.of("out.json"));
The value model was a sealed interface hierarchy — JsonObject, JsonArray,
JsonString, JsonNumber, JsonBoolean, JsonNull — which meant the
compiler enforced exhaustive handling in switch expressions. That's something
Jackson's JsonNode still doesn't give you: if you forget a case, javac
tells you at compile time rather than letting it slip through to production.
We also built a CLI with 15 commands — validate, pretty, compact, get
(JSON Pointer lookup), inspect, roundtrip, build, array, numinfo,
and more — all routed through a single switch expression on args[0] in
JValueCli.java. No picocli. No Commons CLI. Just String[] args.
What the Standard Library Made Painful
Java's standard library is enormous and capable, but it was specifically missing
two things that mattered for this project: a JSON implementation and a test
framework.
No JSON anything. Python ships json. Go ships encoding/json. Ruby ships
json. Java ships... javax.json in Jakarta EE, which isn't in java.base
and requires a runtime provider dependency. The entire reason Jackson exists is
this gap. Building JValue meant writing a complete RFC 8259 parser, a
serializer, string escape handling, Unicode processing, and number grammar
enforcement from nothing but java.lang.String, java.lang.Character, and
java.util.LinkedHashMap.
No test framework. The JDK doesn't ship JUnit — it's a third-party
dependency, which this hackathon didn't allow. We wrote a test harness from
scratch: TestRunner.runTest(String, Runnable) wrapped each test, caught
AssertionError for failures and Exception for errors, accumulated counts,
and exited nonzero on failure. We wrote assertEquals, assertTrue,
assertThrows, and the rest by hand. It worked. It was not fun. No
parameterized test sugar, no IDE integration, no annotation discovery. You
called your test methods from main() and you liked it.
One specific annoyance: fetching external test corpora. We wanted to run
JSONTestSuite's 318 conformance files, but git submodule and curl were
external tools. We ended up writing FetchCorpus.java — a small JDK-only
utility that used java.net.http.HttpClient to download the ZIP archive from
GitHub, java.util.zip.ZipInputStream to extract it, and java.nio.file.Files
to write the files out. It included zip-slip protection. It exited cleanly if
offline so the build didn't break. It was the kind of thing you'd never write if
curl | tar xz were on the table, but it worked, and it was zero-deps.
The Package We Made Look Unnecessary
Jackson (com.fasterxml.jackson). Specifically, the tree-model workflow:
ObjectMapper.readTree() → traverse JsonNode → generate output. JValue
replaced that vertical slice completely. Parse JSON text into a typed tree,
navigate it, serialize it back — all without Jackson, Gson, or any third-party
jar on the classpath.
We weren't claiming Jackson is unnecessary in general. Jackson's streaming API,
its POJO binding, its annotation system, its format modules — none of that was
in JValue, and we didn't pretend it was. What we demonstrated was that the core
tree-parse-serialize workflow — the reason most projects add Jackson in the
first place — was buildable from the JDK in a weekend if you were willing to
write it by hand.
Our STDLIB.md tracked 12 specific substitutions with honest tradeoff
documentation for each: Jackson's ObjectMapper → our recursive-descent parser.
Jackson's JsonGenerator → our hand-written serializer. Guava's ImmutableMap
→ Collections.unmodifiableMap() wrapping LinkedHashMap. JUnit 5 → our test
harness. AssertJ → hand-written assertions. ICU4J → java.lang.Character
surrogate methods. picocli → String[] args switch expressions. Apache Commons
IO → java.nio.file.Files. Each entry documented what we lost, not just what
we gained.
The Edge Case That Ate Real Time
Unicode surrogate pairs.
The JSON spec allows any Unicode code point in a string, but characters outside
the Basic Multilingual Plane (above U+FFFF — emoji, musical symbols, historic
scripts) must be encoded as a pair of \uXXXX escapes: a high surrogate
(U+D800–U+DBFF) followed immediately by a low surrogate (U+DC00–U+DFFF). The
parser had to match them, reject lone surrogates in either direction, and
assemble the actual code point via Character.toCodePoint().
Here's the core of parseUnicodeEscape from JsonParser.java as it shipped:
private char[] parseUnicodeEscape() {
int codeUnit = parseHex4();
if (Character.isHighSurrogate((char) codeUnit)) {
if (source.isAtEnd() || source.peek() != '\\') {
throw source.error("High surrogate U+"
+ String.format("%04X", codeUnit)
+ " must be followed by a low surrogate");
}
source.advance(); // consume '\'
if (source.isAtEnd() || source.peek() != 'u') {
throw source.error("High surrogate U+"
+ String.format("%04X", codeUnit)
+ " must be followed by \\uXXXX low surrogate");
}
source.advance(); // consume 'u'
int lowUnit = parseHex4();
if (!Character.isLowSurrogate((char) lowUnit)) {
throw source.error("Expected low surrogate but found U+"
+ String.format("%04X", lowUnit));
}
int codePoint = Character.toCodePoint(
(char) codeUnit, (char) lowUnit);
return Character.toChars(codePoint);
}
if (Character.isLowSurrogate((char) codeUnit)) {
throw source.error("Unexpected low surrogate U+"
+ String.format("%04X", codeUnit)
+ " without preceding high surrogate");
}
return new char[]{(char) codeUnit};
}
The same strictness showed up on the serialization side — the serializer
validated that Java strings didn't contain lone surrogates before emitting them,
and rejected them with IllegalArgumentException. Both directions had to agree,
or round-trip tests broke.
We also burned real time on the number grammar. RFC 8259's number production
looks trivial — [ minus ] int [ frac ] [ exp ] — but the details compounded:
leading zeros were illegal (except bare 0), a trailing decimal point was
invalid, +1 was invalid, hex notation was invalid, NaN and Infinity were
invalid. We preserved the raw lexeme through parsing so that -0, 1.0, and
1e0 all round-tripped exactly as written — a property Jackson's JsonNode
doesn't guarantee.
What We Shipped
By the Aug 31 code freeze:
- 186 hand-written tests, all passing. Zero failures, zero errors.
-
305 JSONTestSuite conformance cases passing, 0 failed, 13 skipped (the
skipped cases were byte-level encoding tests that didn't apply to our
String-based parser). -
15 CLI commands — routed through a single
switchexpression onargs[0]inJValueCli.java. -
Zero third-party production dependencies, confirmed by
jdeps— every production class depended only onjava.base.
The CLI was the part that made the project feel like a real tool rather than just
a library:
$ ./jv.sh validate data.json
✔ Valid JSON
Root type : object
Keys : 3
$ ./jv.sh get data.json /users/0/name
Pointer : /users/0/name
Type : string
Value :
"Ada Lovelace"
What We Learned
Building a JSON parser from scratch turned out to be deeply educational and
moderately miserable. We learned more about RFC 8259 in that weekend than we had
in a decade of using Jackson. We also discovered that the spec was full of small
traps — surrogate pairs, the exact set of legal whitespace characters, whether a
leading BOM counts as valid input (we rejected it), whether trailing commas are
allowed (they are not) — that libraries handle invisibly.
If we're honest, the actual hard part of this weekend wasn't writing Unicode-handling code — most of that came together faster than either of us expected once the architecture was in place. The hard part was deciding how much to trust it. It's easy to generate a recursive-descent parser quickly now; it's much harder to know, with a submission deadline closing in, whether the thing you generated actually does what it claims. So most of our real effort in the last stretch went into verification rather than writing: running the parser against files we hadn't tested before and reading the output ourselves instead of trusting a green checkmark, deliberately breaking things to see if the errors pointed where they should, checking that a file written on one path and read back on another actually agreed. None of that shows up as a diff in the repo. If we had to hand one piece of advice to the next team building this way, it wouldn't be about surrogate pairs — it's that when an AI can generate the hard part in an afternoon, the bottleneck quietly moves to verifying it, and that's the part you can't delegate.
We disclosed every tradeoff. Our STDLIB.md didn't just list substitutions; it
documented what we lost — no POJO binding, no streaming, no configurable
pretty-printing. Our README.md had a Limitations section. Overclaiming costs
more than it gains when judges are senior engineers who will read the source.
If we had the weekend back, there are things we'd explore — Reader/
InputStream parsing, streaming serialization, maybe JSON Patch. But the point
of the project was never to replace Jackson wholesale. It was to answer a
narrower question: for the core tree-parse-serialize workflow that most projects
actually use Jackson for, do we still know how to build that ourselves? By
submission, we had 186 passing tests, 305 conformance cases, and a jdeps
report that said java.base and nothing else. The answer was yes.
Built for ZeroDepsHack 2026 — Track B: Parsers & Data Formats. Organized by Hackathon Raptors.
This article was originally published by DEV Community and written by Devansh Kant Kashyap.
Read original article on DEV Community