json-io supports two formats for both reading and writing:
- JSON — Standard JSON and JSON5 for universal interoperability
- TOON — Token-Oriented Object Notation for LLM-optimized serialization (~40-50% fewer tokens)
Both formats can be used directly with Strings or Java's Streams.
Example 1: Java object graph to JSON String
Employee emp;
// Emp fetched from database
String json = JsonIo.toJson(emp, writeOptions);This example will convert the Employee instance to a JSON String, including nested sub-objects. If
JsonIo.toJava(json, readOptions).asClass(Employee.class) were used on this JSON String, a new Java Employee instance would be returned.
Performance tip: If your data has no circular references (e.g., DTOs, simple POJOs), use cycleSupport(false) for ~35-40% faster serialization:
WriteOptions fastOptions = new WriteOptionsBuilder().cycleSupport(false).build();
String json = JsonIo.toJson(emp, fastOptions);See WriteOptions reference for list of all WriteOptions and instructions on how to use.
Example 2: String to Java object
String json = // String JSON representing Employee instance
Employee employee = JsonIo.toJava(json, readOptions).asClass(Employee.class);This will convert the JSON String back to a Java Object graph.
See ReadOptions reference for a list of all ReadOptions and instructions on how to use.
Example 3: Java Object to OutputStream
Employee emp;
// emp obtained from data store...
JsonIo.toJson(outputStream, emp, writeOptions); In this example, a Java object is written to an OutputStream in JSON format. The stream is closed when finished. If
you need to keep the OutputStream open (e.g. NDJSON), then set writeOptions.closeStream(false). Example:
WriteOptions writeOptions = new WriteOptionsBuilder().closeStream(false).build();
JsonIo.toJson(outputStream, record1, writeOptions);
JsonIo.toJson(outputStream, record2, writeOptions);
...
JsonIo.toJson(outputStream, recordn, writeOptions);
outputStream.close();Example 4: InputStream to Java object
List<Employee> list = JsonIo.toJava(stream, readOptions).asType(new TypeHolder<List<Employee>>(){});In this example, an InputStream is supplying the JSON.
json-io provides two distinct modes for reading JSON. In addition to typed Java objects, you can use the Map Mode
which returns a generic Map<String, Object> graph without requiring Java classes on the classpath.
Use toMaps() for class-independent JSON parsing:
// Parse JSON object to Map
Map<String, Object> root = JsonIo.toMaps(json).asClass(Map.class);
// Parse JSON array to List
List<Object> list = JsonIo.toMaps("[1,2,3]").asClass(List.class);
// Parse any JSON type (primitives, objects, arrays)
Object result = JsonIo.toMaps(json).asClass(null);
// Or from InputStream
Map<String, Object> root = JsonIo.toMaps(inputStream).asClass(Map.class);
// With custom options
ReadOptions readOptions = new ReadOptionsBuilder()
.aliasTypeName("OldClass", "NewClass")
.build();
Map<String, Object> root = JsonIo.toMaps(json, readOptions).asClass(Map.class);The toMaps() API automatically configures the reader for Map mode and allows unknown @type values to be parsed
successfully. It returns a JsonValue builder that allows you to extract the result as any type (Map, List, primitives, etc.).
The parsed structure forms a graph consisting of Map instances (actually JsonObject with deterministic
LinkedHashMap ordering), arrays, and primitive types.
When Map is returned, the root value can represent one of the following:
- JSON Object (
{...}): Transformed into aMapthat represents any JSON object{...}. - JSON Array (
[...]): Represented as aMapwith a key of@itemswhich holds the list representing the JSON array[...]. - JSON Primitive: Such as boolean (true/false), null, numbers (long, double), and strings, directly represented as their Java equivalents.
This Map representation can be rewritten to a JSON String or Stream, ensuring that the output JSON will match the
original input JSON stream. This feature is especially useful for handling JSON strings or streams containing class
references not present in the JVM parsing the JSON. It allows complete reading and potential manipulation of the content,
followed by rewriting the String or stream, providing a robust solution for dynamic data handling.
Example: Manipulate JSON without having the referenced classes
// Read JSON into Map structure (no classes required!)
Map<String, Object> jsonMap = JsonIo.toMaps(jsonString).asClass(Map.class);
// Manipulate values
jsonMap.put("name", "John Doe");
((Map)jsonMap.get("address")).put("city", "New York");
// Write back to JSON
String updatedJson = JsonIo.toJson(jsonMap, writeOptions);Each JsonObject retains the raw @type value from the input JSON. You can safely cast to JsonObject
to access this metadata:
JsonObject obj = JsonIo.toMaps(jsonString).asClass(JsonObject.class);
String originalType = obj.getTypeString(); // Preserved @type valueIf the JSON contains class references (@type entries) that are not available on the classpath,
use the toMaps() API which automatically handles unknown types gracefully:
// Simple - automatically allows unknown types
Map<String, Object> graph = JsonIo.toMaps(json).asClass(Map.class);The toMaps() method automatically configures the reader to skip type resolution and return a graph of Map
instances. This allows arbitrary JSON to be loaded, inspected, and re-serialized without requiring the
referenced classes on the classpath.
All objects will be represented as Maps (or collections) so the entire structure can be traversed or modified.
The original @type strings are preserved in the JsonObject metadata for later use if needed.
Advanced: If you need strict type validation even in Map mode, you can override the default:
ReadOptions opts = new ReadOptionsBuilder()
.failOnUnknownType(true) // Override default
.build();
Map<String, Object> graph = JsonIo.toMaps(json, opts).asClass(Map.class); // Will throw if unknown @type foundFor working with generic types like List<Employee> or complex nested generics, use the TypeHolder class to preserve full generic type information:
// Reading a list of employees
List<Employee> employees = JsonIo.toJava(json, readOptions)
.asType(new TypeHolder<List<Employee>>(){});
// Reading a complex nested structure
Map<String, List<Department<Employee>>> orgMap = JsonIo.toJava(json, readOptions)
.asType(new TypeHolder<Map<String, List<Department<Employee>>>>(){});JSON5 is an extension to JSON that makes it more human-friendly by adding features inspired by ECMAScript 5. json-io provides complete JSON5 support for both reading and writing — the only major Java JSON library to do so natively.
By default, json-io accepts all JSON5 extensions. This means you can parse JSON5 files without any configuration:
// JSON5 is accepted by default - no configuration needed
String json5 = """
{
// This is a comment
name: 'John', // unquoted key, single-quoted string
age: 30, // trailing comma allowed
salary: 0xFFFF, // hexadecimal number
rating: .95, // leading decimal point
}
""";
Person person = JsonIo.toJava(json5, null).asClass(Person.class);| Feature | Example | Supported |
|---|---|---|
| Single-line comments | // comment |
✅ |
| Block comments | /* comment */ |
✅ |
| Unquoted object keys | {name: "John"} |
✅ |
| Single-quoted strings | {'name': 'John'} |
✅ |
| Trailing commas | [1, 2, 3,] |
✅ |
| Hexadecimal numbers | 0xFF or 0xff |
✅ |
| Leading decimal point | .5 (equals 0.5) |
✅ |
| Trailing decimal point | 5. (equals 5.0) |
✅ |
| Explicit positive sign | +5 |
✅ |
| Infinity literal | Infinity, -Infinity |
✅ |
| NaN literal | NaN |
✅ |
| Multi-line strings | "line1\↵line2" (backslash continuation) |
✅ |
If you need to enforce strict RFC 8259 JSON compliance (rejecting JSON5 extensions), use strictJson():
ReadOptions strictOptions = new ReadOptionsBuilder()
.strictJson()
.build();
// This will throw JsonIoException because of the comment
String json5 = "{ // comment\n\"name\": \"John\"}";
JsonIo.toJava(json5, strictOptions).asClass(Map.class); // Throws!When strictJson() is enabled, the following will cause parse errors:
- Comments (single-line or block)
- Unquoted object keys
- Single-quoted strings
- Trailing commas
- Hexadecimal numbers
- Non-standard number formats (.5, 5., +5)
- Infinity and NaN literals
For TOON parsing strictness, use strictToon() on ReadOptionsBuilder:
ReadOptions strictToonOptions = new ReadOptionsBuilder()
.strictToon()
.build();strictToon() is off by default for backward compatibility. When enabled, TOON parsing enforces strict
format checks such as indentation validity, array count matching, delimiter/header consistency, and rejection
of blank lines inside list/tabular arrays.
json-io can also write JSON5 format, which no other major Java JSON library supports natively. JSON5 write features are disabled by default to ensure maximum compatibility, but can be enabled individually or via an umbrella option.
The simplest way to enable JSON5 writing is the json5() umbrella method, which enables the most
commonly useful features:
WriteOptions json5Options = new WriteOptionsBuilder()
.json5() // Enables: unquoted keys, smart quotes, Infinity/NaN
.build();
String json5 = JsonIo.toJson(myObject, json5Options);The json5() umbrella enables:
- Unquoted keys — object keys that are valid identifiers are written without quotes
- Smart quotes — strings containing
"(but not') use single quotes for cleaner output - Infinity/NaN literals — special float/double values written as literals instead of
null
Note: Trailing commas are not enabled by
json5()— they require explicit opt-in since they provide no semantic benefit and some tools still don't accept them.
For fine-grained control, enable features individually:
WriteOptions options = new WriteOptionsBuilder()
.json5UnquotedKeys(true) // Write unquoted keys when valid identifiers
.json5SmartQuotes(true) // Use single quotes for strings with embedded "
.json5InfinityNaN(true) // Write Infinity/NaN as literals
.json5TrailingCommas(true) // Add trailing commas (explicit opt-in)
.build();| Option | Effect | Example Output |
|---|---|---|
json5UnquotedKeys(true) |
Keys that are valid ECMAScript identifiers are unquoted | {name:"John"} instead of {"name":"John"} |
json5SmartQuotes(true) |
Strings with " but no ' use single quotes |
'He said "Hi"' instead of "He said \"Hi\"" |
json5InfinityNaN(true) |
Special values written as literals | Infinity instead of null |
json5TrailingCommas(true) |
Trailing comma after last element | [1,2,3,] and {a:1,} |
Default behavior: Standard JSON mode uses @ prefix (quoted keys like "@type":), while JSON5 mode uses $ prefix (unquoted keys like $type:). These defaults can be overridden using useMetaPrefixAt() or useMetaPrefixDollar().
Combined with the short meta keys option, there are four possible meta key formats:
| Mode | Type | ID | Ref | Items | Keys |
|---|---|---|---|---|---|
| Standard | "@type": |
"@id": |
"@ref": |
"@items": |
"@keys": |
| Short | "@t": |
"@i": |
"@r": |
"@e": |
"@k": |
| JSON5 | $type: |
$id: |
$ref: |
$items: |
$keys: |
| JSON5 + Short | $t: |
$i: |
$r: |
$e: |
$k: |
Why $ instead of @?
- In JSON5, object keys can be unquoted if they are valid ECMAScript identifiers
@is not a valid identifier start character, so@typecannot be written unquoted$is a valid identifier start character, so$typecan be written unquoted in JSON5$also has precedent in JSON Schema ($schema,$id,$ref)
Reading compatibility: json-io accepts all meta key variants when reading (@type, @t, $type, $t), ensuring backward compatibility regardless of which format was used to write the JSON.
// JSON5 output with unquoted meta keys
WriteOptions json5Options = new WriteOptionsBuilder().json5().build();
String json5 = JsonIo.toJson(myObject, json5Options);
// Output: {$type:"com.example.MyClass",$id:1,...}
// JSON5 + short meta keys (most compact)
WriteOptions json5ShortOptions = new WriteOptionsBuilder()
.json5()
.shortMetaKeys(true)
.build();
String json5Short = JsonIo.toJson(myObject, json5ShortOptions);
// Output: {$t:"com.example.MyClass",$i:1,...}
// Standard output with quoted meta keys
WriteOptions stdOptions = new WriteOptionsBuilder().build();
String stdJson = JsonIo.toJson(myObject, stdOptions);
// Output: {"@type":"com.example.MyClass","@id":1,...}
// Short meta keys (standard mode)
WriteOptions shortOptions = new WriteOptionsBuilder()
.shortMetaKeys(true)
.build();
String shortJson = JsonIo.toJson(myObject, shortOptions);
// Output: {"@t":"com.example.MyClass","@i":1,...}
// Reading works with any prefix
Object obj1 = JsonIo.toJava("{$type:\"java.util.HashMap\"}", null).asClass(Object.class);
Object obj2 = JsonIo.toJava("{$t:\"java.util.HashMap\"}", null).asClass(Object.class);
Object obj3 = JsonIo.toJava("{\"@type\":\"java.util.HashMap\"}", null).asClass(Object.class);
Object obj4 = JsonIo.toJava("{\"@t\":\"java.util.HashMap\"}", null).asClass(Object.class);You can force a specific prefix regardless of the JSON mode using useMetaPrefixAt() or useMetaPrefixDollar():
// Force @ prefix even in JSON5 mode (keys will be quoted since @ requires quotes)
WriteOptions options = new WriteOptionsBuilder()
.json5()
.useMetaPrefixAt()
.build();
String json = JsonIo.toJson(myObject, options);
// Output: {"@type":"com.example.MyClass", name:"John", ...}
// Force $ prefix in standard JSON mode (keys will be quoted)
WriteOptions options = new WriteOptionsBuilder()
.useMetaPrefixDollar()
.build();
String json = JsonIo.toJson(myObject, options);
// Output: {"$type":"com.example.MyClass", "name":"John", ...}This is useful for:
- Interoperability: When communicating with systems that expect a specific prefix
- JSON Schema alignment: The
$prefix is used in JSON Schema ($schema,$id,$ref) - Migration: Maintaining prefix consistency when transitioning between formats
See WriteOptions Meta Key Prefix Override for detailed documentation.
Keys are only unquoted if they are valid ECMAScript identifiers:
- Must start with: letter (a-z, A-Z), underscore (
_), or dollar sign ($) - May contain: letters, digits (0-9), underscores, or dollar signs
- Keys that don't meet these criteria remain quoted
Map<String, Object> map = new LinkedHashMap<>();
map.put("validKey", 1); // Will be unquoted: validKey:1
map.put("_private", 2); // Will be unquoted: _private:2
map.put("$ref", 3); // Will be unquoted: $ref:3
map.put("key-with-dash", 4); // Will be quoted: "key-with-dash":4
map.put("123numeric", 5); // Will be quoted: "123numeric":5Smart quotes only affect string values, not keys. Keys are handled separately by json5UnquotedKeys().
Key behavior (controlled by json5UnquotedKeys):
- Valid identifier → unquoted:
name: - Invalid identifier → double-quoted:
"key-with-dash": - Keys never use single quotes, even if they contain
"
Value behavior (controlled by json5SmartQuotes):
- If string contains
"but no'→ single quotes (avoids escaping) - Otherwise → double quotes (standard behavior)
// With json5UnquotedKeys(true) and json5SmartQuotes(true):
map.put("name", "He said \"Hello\""); // Output: name:'He said "Hello"'
map.put("key-dash", "He said \"Hi\""); // Output: "key-dash":'He said "Hi"'
map.put("msg", "It's fine"); // Output: msg:"It's fine"Note how "key-dash" uses double quotes (invalid identifier) while its value uses single quotes (contains ").
// Create test data
Map<String, Object> data = new LinkedHashMap<>();
data.put("name", "John");
data.put("message", "He said \"Hello\"");
data.put("score", Double.POSITIVE_INFINITY);
data.put("rating", Double.NaN);
// Write with full JSON5 features
WriteOptions options = new WriteOptionsBuilder()
.json5() // Enable umbrella features
.json5TrailingCommas(true) // Also enable trailing commas
.prettyPrint(true)
.showTypeInfoNever()
.build();
String json5 = JsonIo.toJson(data, options);Output:
{
name: "John",
message: 'He said "Hello"',
score: Infinity,
rating: NaN,
}This JSON5 output can be read back by json-io (or any JSON5-compliant parser) without any issues.
TOON (Token-Oriented Object Notation) is a compact, human-readable format optimized for LLM token efficiency, using approximately 40-50% fewer tokens than equivalent JSON. json-io fully supports both reading and writing TOON format.
// Convert any Java object to TOON
Person person = new Person("John", 30);
String toon = JsonIo.toToon(person, null);
// Or stream to an OutputStream
JsonIo.toToon(outputStream, person, writeOptions);TOON defaults with null write options:
- Type metadata is omitted (
showTypeInfoNever()behavior). - Cycle support is disabled (
cycleSupport(false)) for ~35-40% faster serialization — TOON targets LLM communication where data is typically acyclic. If a cycle is encountered, aJsonIoExceptionis thrown with guidance to enablecycleSupport(true). - Meta keys use
$prefix by default (for JSON5-friendly identifiers). - You can override any of these with explicit
WriteOptionsBuildersettings.
// Parse TOON to typed Java object
Person person = JsonIo.fromToon(toon, readOptions).asClass(Person.class);
// Parse TOON with generic types using TypeHolder
List<Person> people = JsonIo.fromToon(toon, readOptions)
.asType(new TypeHolder<List<Person>>() {});
// Parse TOON to Map structure (class-independent)
Map<String, Object> map = JsonIo.fromToonToMaps(toon, readOptions).asClass(Map.class);
// Stream from InputStream
Person person = JsonIo.fromToon(inputStream, readOptions).asClass(Person.class);The fromToon() API returns a fluent builder allowing:
.asClass(Class<T>)— Parse and convert to specific class.asType(TypeHolder<T>)— Parse with full generic type information (e.g.,List<Person>,Map<String, Person>)
TOON uses a line-oriented, indentation-based structure:
| Feature | JSON | TOON |
|---|---|---|
| Object delimiters | { } |
Indentation (2 spaces) |
| Array delimiters | [ ] |
Count prefix: [N]: |
| Key-value separator | "key": value |
key: value |
| String quoting | Always quoted | Only when necessary |
| Line endings | Any | LF only |
Java object:
Map<String, Object> data = new LinkedHashMap<>();
data.put("name", "John");
data.put("age", 30);
data.put("tags", Arrays.asList("java", "json", "toon"));
Map<String, Object> address = new LinkedHashMap<>();
address.put("city", "NYC");
address.put("zip", 10001);
data.put("address", address);JSON output (91 characters):
{"name":"John","age":30,"tags":["java","json","toon"],"address":{"city":"NYC","zip":10001}}JSON5 output (79 characters, ~13% smaller):
{name:"John",age:30,tags:["java","json","toon"],address:{city:"NYC",zip:10001}}TOON output (54 characters, ~41% smaller):
name: John
age: 30
tags[3]: java,json,toon
address:
city: NYC
zip: 10001
| Format | Size | Savings vs JSON |
|---|---|---|
| JSON | 91 chars | — |
| JSON5 | 79 chars | ~13% smaller |
| TOON | 54 chars | ~41% smaller |
TOON only quotes strings when necessary. Strings are quoted when they:
- Are empty
- Have leading/trailing whitespace
- Equal
true,false, ornull - Look like numbers (e.g.,
"42","3.14") - Contain special characters:
:,",\,[,],{,}, newlines, tabs - Contain the delimiter character (comma by default)
- Start with hyphen
// These strings will NOT be quoted:
"hello" → hello
"NYC" → NYC
// These strings WILL be quoted:
"" → ""
"true" → "true"
"42" → "42"
"hello:world" → "hello:world"
" spaced " → " spaced "Primitive arrays (inline format):
tags[3]: foo,bar,baz
numbers[5]: 1,2,3,4,5
Complex arrays (list format with hyphens):
employees[2]:
-
name: Alice
age: 25
-
name: Bob
age: 30
Per the TOON specification:
NaN→nullInfinity/-Infinity→null-0→0
TOON only supports 5 escape sequences (fewer than JSON):
\\— backslash\"— double quote\n— newline\r— carriage return\t— tab
json-io's TOON support includes all types supported by the java-util Converter. This provides comprehensive coverage far exceeding other TOON implementations:
| Category | Types | TOON Output |
|---|---|---|
| Primitives & Wrappers | byte, Byte, short, Short, int, Integer, long, Long, float, Float, double, Double, boolean, Boolean, char, Character |
Numeric form or true/false |
| Big Numbers | BigInteger, BigDecimal |
Numeric (unquoted) |
| Atomic Types | AtomicInteger, AtomicLong, AtomicBoolean |
Unwrapped numeric/boolean value |
| Date/Time (java.util) | Date, Calendar, TimeZone |
ISO-8601 string |
| Date/Time (java.sql) | java.sql.Date, Timestamp |
ISO-8601 string |
| Date/Time (java.time) | Instant, Duration, Period, LocalDate, LocalTime, LocalDateTime, ZonedDateTime, OffsetDateTime, OffsetTime, Year, YearMonth, MonthDay, ZoneId, ZoneOffset |
ISO-8601 string |
| String Types | String, StringBuffer, StringBuilder, char[], Character[], CharBuffer |
String value (quoted if needed) |
| Binary/Buffer | byte[], ByteBuffer, BitSet |
Base64 encoded string |
| ID/Location | UUID, URI, URL, Locale, Currency, Pattern, File, Path, Class |
String representation |
| Geometric (java-util) | Color, Dimension, Point, Rectangle, Insets |
Object with component fields |
| Collections | List, Set, Collection, EnumSet |
Array [N]: ... or list format |
| Maps | Map, HashMap, TreeMap, etc. |
Object with key-value pairs |
| Arrays | Object[], primitive arrays (int[], double[], etc.) |
Inline [N]: a,b,c or list format |
| Enums | Any Enum type |
Enum name as string |
| Other | Throwable, custom objects with accessible fields |
Object with fields |
Comparison with JToon (toon-format/toon-java):
| Feature | json-io | JToon |
|---|---|---|
| Primitive types | ✓ All 8 | ✓ All 8 |
| Date/Time types | ✓ 18 types | ✓ 8 types |
| Atomic types | ✓ 3 types | ✗ |
| Geometric types | ✓ 5 types | ✗ |
UUID, URI, URL |
✓ | ✗ |
Locale, Currency, Pattern |
✓ | ✗ |
File, Path, Class |
✓ | ✗ |
BitSet, ByteBuffer, CharBuffer |
✓ | ✗ |
StringBuffer, StringBuilder |
✓ | ✗ |
| Complex object graphs | ✓ Full support | Limited |
| Cyclic reference handling | ✓ $id/$ref (or @id/@ref) |
N/A |
| Generic type preservation | ✓ Via TypeHolder | ✗ |
| Custom type extensibility | ✓ ClassFactory/CustomReader | Limited |
| Delimiter options (comma/tab/pipe) | ✓ | ✓ |
Use TOON when:
- Communicating with LLMs (significant token savings in both prompts and responses)
- Human readability is important
- You want compact, fast payloads — TOON defaults to
cycleSupport(false)for optimal performance; enablecycleSupport(true)explicitly if your data has cycles
Use JSON when:
- Interoperability with other systems is required
- You need strict JSON tooling compatibility
- Standard JSON tooling is needed
TOON defaults to cycleSupport(false) when null is passed for WriteOptions, skipping the
traceReferences() pre-pass for ~35-40% faster serialization. This is appropriate because TOON
targets LLM communication where data is typically acyclic (DTOs, query results, API responses).
If an actual cycle is encountered, a JsonIoException is thrown with guidance to enable
cycleSupport(true). To write cyclic object graphs in TOON, pass explicit options:
WriteOptions options = new WriteOptionsBuilder().cycleSupport(true).build();
String toon = JsonIo.toToon(cyclicObject, options);json-io provides 26 annotations in the com.cedarsoftware.io.annotation package for controlling serialization and deserialization. In addition, json-io reflectively honors Jackson annotations when the Jackson JAR is on the classpath — with zero compile-time dependency on Jackson.
When both json-io and Jackson annotations are present on the same element:
- Programmatic API (
WriteOptionsBuilder/ReadOptionsBuildermethods likeaddExcludedField(),addClassFactory(), etc.) — highest priority, always wins - json-io annotations (
@IoProperty,@IoIgnore, etc.) — checked first among annotations - Jackson annotations (
@JsonProperty,@JsonIgnore, etc.) — used as fallback if no json-io annotation is present on the same element
If Jackson annotations are not on the classpath, external annotation detection is silently skipped with zero overhead.
For type-resolution annotations on fields, a more specific precedence applies:
@typein JSON >@IoDeserialize>@IoTypeInfo> declared field type
Renames a Java field in the serialized JSON output and accepts the renamed key during deserialization. Equivalent to Jackson's @JsonProperty.
public class User {
@IoProperty("full_name")
private String name; // Serializes as "full_name" in JSON
private int age;
}Excludes a field from both serialization and deserialization. Equivalent to Jackson's @JsonIgnore.
public class Account {
private String username;
@IoIgnore
private String password; // Never appears in JSON, never read from JSON
}Excludes multiple fields by name at the class level. Equivalent to Jackson's @JsonIgnoreProperties.
@IoIgnoreProperties({"secret", "internal"})
public class Config {
private String name;
private String secret; // Excluded
private String internal; // Excluded
private int value;
}The inverse of @IoIgnoreProperties — only the listed fields are included in serialization and deserialization. All other fields are excluded. Equivalent to Jackson's @JsonIncludeProperties.
@IoIncludeProperties({"name", "email"})
public class User {
private String name; // Included
private String email; // Included
private String password; // Excluded (not in whitelist)
private int age; // Excluded (not in whitelist)
}Specifies alternate JSON property names accepted during deserialization. The primary field name (or @IoProperty name) is always used for serialization. Equivalent to Jackson's @JsonAlias.
public class Person {
@IoAlias({"firstName", "first_name", "fname"})
private String name;
}
// Any of {"name":"Alice"}, {"firstName":"Alice"}, {"first_name":"Alice"}, {"fname":"Alice"}
// will deserialize correctly into the 'name' field.Controls the order of fields during JSON serialization. Fields listed in the annotation appear first in the specified order; any remaining fields follow in their natural declaration order. Equivalent to Jackson's @JsonPropertyOrder.
@IoPropertyOrder({"id", "name", "email"})
public class User {
private String email;
private String name;
private long id;
private int age;
}
// Serializes as: {"id":1, "name":"Alice", "email":"[email protected]", "age":30}Controls the inclusion of a field during serialization. When set to NON_NULL, the field is omitted from JSON output if its value is null, regardless of the global skipNullFields setting. Equivalent to Jackson's @JsonInclude(Include.NON_NULL).
public class Response {
@IoInclude(IoInclude.Include.NON_NULL)
private String optionalMessage; // Omitted from JSON when null
private String status; // Always present, even if null
}Applies a naming strategy to all fields in a class, transforming Java camelCase names to the chosen format. Individual fields can override the strategy with @IoProperty. Equivalent to Jackson's @JsonNaming.
Available strategies: SNAKE_CASE, KEBAB_CASE, UPPER_CAMEL_CASE, LOWER_DOT_CASE.
@IoNaming(IoNaming.Strategy.SNAKE_CASE)
public class UserProfile {
private String firstName; // Serializes as "first_name"
private String lastName; // Serializes as "last_name"
private int loginCount; // Serializes as "login_count"
@IoProperty("uid")
private String userId; // Overrides strategy — serializes as "uid"
}Marks a constructor or static factory method for json-io to use during deserialization. Parameters are matched to JSON keys by name (or via @IoProperty on parameters). Equivalent to Jackson's @JsonCreator.
public class Money {
private final long cents;
private final String currency;
@IoCreator
Money(@IoProperty("cents") long cents, @IoProperty("currency") String currency) {
this.cents = cents;
this.currency = currency;
}
}
// Static factory also supported:
public class Color {
private final int r, g, b;
private Color(int r, int g, int b) { this.r = r; this.g = g; this.b = b; }
@IoCreator
static Color of(@IoProperty("r") int r, @IoProperty("g") int g, @IoProperty("b") int b) {
return new Color(r, g, b);
}
}Marks a no-arg instance method whose return value is used as the serialized representation. Useful for wrapper types that should serialize as a single value rather than an object with fields. Equivalent to Jackson's @JsonValue.
public class EmailAddress {
private final String address;
@IoCreator
EmailAddress(@IoProperty("address") String address) { this.address = address; }
@IoValue
public String toValue() { return address; }
}
// Serializes as: "[email protected]" (not {"address":"[email protected]"})When placed on a class, all fields of that type are excluded from serialization and deserialization across all classes. Useful for cross-cutting exclusion of internal metadata types. Equivalent to Jackson's @JsonIgnoreType.
@IoIgnoreType
public class InternalMetadata {
private String traceId;
private long timestamp;
}
public class Order {
private String orderId;
private InternalMetadata meta; // Automatically excluded everywhere
private String status;
}Field-level annotation that specifies the default concrete type to use during deserialization when no @type metadata is present in the JSON. Useful for polymorphic fields declared as interfaces or abstract classes. If @type IS present in the JSON, it takes precedence. Equivalent to Jackson's @JsonTypeInfo(defaultImpl=...).
public class Container {
@IoTypeInfo(ArrayList.class)
private Object items; // Defaults to ArrayList when @type absent
@IoTypeInfo(LinkedHashMap.class)
private Map<String, Object> data; // Defaults to LinkedHashMap
}Write-side benefit: When the runtime type of a field matches the @IoTypeInfo type, @type is omitted from JSON output — the reader infers the type from the annotation, producing smaller JSON.
Field-level or class-level annotation that always overrides the declared type during deserialization (forced coercion). Unlike @IoTypeInfo, this is not just a default — it is always applied unless @type is present in the JSON. Equivalent to Jackson's @JsonDeserialize(as=...).
public class Config {
@IoDeserialize(as = LinkedList.class)
private List<String> items; // Always deserialized as LinkedList
@IoDeserialize(as = LinkedHashMap.class)
private Map<String, Object> data; // Always deserialized as LinkedHashMap
}Difference from @IoTypeInfo: @IoDeserialize(as=...) always overrides the declared type (forced coercion). @IoTypeInfo only provides a default when no type can be inferred. When both are present on the same field, @IoDeserialize takes priority.
Write-side benefit: When the runtime type of a field matches the @IoDeserialize(as=X) type, @type is omitted from JSON output — the reader infers the type from the annotation, producing smaller JSON.
Class-level annotation that specifies a ClassFactory implementation to use when deserializing instances of this class. The factory class must have a no-arg constructor. Factory instances are automatically cached and shared. Programmatic addClassFactory() takes priority.
@IoClassFactory(WidgetFactory.class)
public class Widget {
private final String name;
private final int size;
private Widget(String name, int size) { this.name = name; this.size = size; }
}
public class WidgetFactory implements ClassFactory {
public Object newInstance(Class<?> c, JsonObject jObj, Resolver resolver) {
String name = (String) jObj.get("name");
int size = ((Number) jObj.get("size")).intValue();
return new Widget(name, size);
}
public boolean isObjectFinal() { return true; } // Factory fully populates the object
}Method-level annotation that marks a no-arg instance method as the getter for a specific field during serialization. Use this when your class uses non-standard getter names that don't follow the getXxx() convention. Programmatic addNonStandardGetter() takes priority.
public class Sensor {
private double temperature;
private String location;
@IoGetter("temperature")
public double readTemperature() { return temperature; }
@IoGetter("location")
public String fetchLocation() { return location; }
}
// json-io calls readTemperature() and fetchLocation() instead of getTemperature()/getLocation()Method-level annotation that marks a single-argument instance method as the setter for a specific field during deserialization. Use this when your class uses non-standard setter names that don't follow the setXxx() convention. Programmatic addPermanentNonStandardSetter() takes priority.
public class Sensor {
private double temperature;
private String location;
@IoSetter("temperature")
public void calibrateTemperature(double temp) { this.temperature = temp; }
@IoSetter("location")
public void assignLocation(String loc) { this.location = loc; }
}
// json-io calls calibrateTemperature() and assignLocation() instead of setTemperature()/setLocation()Note: @IoGetter/@IoSetter are the annotation equivalents of the nonStandardGetters.txt and nonStandardSetters.txt config files. Config files are used for JDK classes that cannot be annotated; annotations are for user classes.
Marks a class as non-referenceable: instances of this type will never emit @id or @ref during serialization, even when the same instance is referenced multiple times. This is the annotation equivalent of the nonRefs.txt config file. No Jackson equivalent exists — Jackson does not support @id/@ref graph semantics.
@IoNonReferenceable
public class Token {
private String value;
// ...
}
public class Holder {
private Token first;
private Token second;
}
Token t = new Token("abc");
Holder h = new Holder();
h.first = t;
h.second = t; // same instance
String json = JsonIo.toJson(h, writeOptions);
// {"first":{"value":"abc"},"second":{"value":"abc"}}
// No @id/@ref — second is written as a full duplicateOn deserialization, each occurrence produces a separate object instance (no instance sharing). This is appropriate for value-like types where identity is not meaningful.
Note: @IoNonReferenceable is additive with the programmatic API (addNonReferenceableClass()) and the nonRefs.txt config file. All three sources are OR'd together. The config file is used for JDK classes that cannot be annotated; the annotation is for user classes.
Marks a class to prevent custom reader usage during deserialization. Even if a custom reader exists for a parent class (through inheritance), the annotated class will use standard field-by-field deserialization instead. This is the annotation equivalent of the notCustomRead.txt config file.
@IoNotCustomReader
public class MySpecialSet extends HashSet<String> {
private int metadata;
// Will NOT use HashSet's custom reader — uses standard field-by-field deserialization
}Note: @IoNotCustomReader is additive with the programmatic API (addNotCustomReaderClass()) and the notCustomRead.txt config file.
Marks a class to prevent custom writer usage during serialization. Even if a custom writer exists for a parent class (through inheritance), the annotated class will use standard field-by-field serialization instead. This is the annotation equivalent of the notCustomWritten.txt config file.
@IoNotCustomWritten
public class MySpecialMap extends HashMap<String, Object> {
private String label;
// Will NOT use HashMap's custom writer — uses standard field-by-field serialization
}Both annotations can be combined on the same class:
@IoNotCustomReader
@IoNotCustomWritten
public class MySpecialCollection extends ArrayList<String> {
// Standard serialization AND deserialization — no custom reader or writer
}Note: @IoNotCustomWritten is additive with the programmatic API (addNotCustomWrittenClass()) and the notCustomWritten.txt config file. Config files are used for JDK classes that cannot be annotated; annotations are for user classes.
Specifies a JsonClassWriter implementation to use when serializing instances of this class. This is the annotation equivalent of calling WriteOptionsBuilder.addCustomWrittenClass(Class, JsonClassWriter) or adding an entry to customWriters.txt.
The writer class must have a public no-arg constructor. Instances are cached and shared.
@IoCustomWriter(MoneyWriter.class)
public class Money {
private BigDecimal amount;
private Currency currency;
}
public class MoneyWriter implements JsonClassWriter {
public void write(Object o, boolean showType, Writer output, WriterContext context) throws IOException {
if (showType) { output.write("\"value\":"); }
writePrimitiveForm(o, output, context);
}
public boolean hasPrimitiveForm(WriterContext context) { return true; }
public void writePrimitiveForm(Object o, Writer output, WriterContext context) throws IOException {
Money m = (Money) o;
output.write("\"" + m.getAmount() + " " + m.getCurrency() + "\"");
}
}Note: Programmatic addCustomWrittenClass() takes priority over the annotation.
Specifies a JsonClassReader implementation to use when deserializing instances of this class. This is the annotation equivalent of calling ReadOptionsBuilder.addCustomReaderClass(Class, JsonClassReader) or adding an entry to customReaders.txt.
The reader class must have a public no-arg constructor. Instances are cached and shared.
@IoCustomReader(MoneyReader.class)
public class Money {
private BigDecimal amount;
private Currency currency;
}
public class MoneyReader implements JsonClassReader {
public Object read(Object jsonObj, Resolver resolver) {
String s = (jsonObj instanceof String) ? (String) jsonObj : (String) ((JsonObject) jsonObj).get("value");
String[] parts = s.split(" ");
return new Money(new BigDecimal(parts[0]), Currency.getInstance(parts[1]));
}
}Both annotations can be combined on the same class to provide custom serialization in both directions. Note: Programmatic addCustomReaderClass() takes priority over the annotation. Config files are used for JDK classes that cannot be annotated; annotations are for user classes.
Assigns a short alias name to a class for use in the @type field during JSON serialization. Instead of writing the fully-qualified class name, json-io will write the alias. On deserialization, the alias is resolved back to the original class. This is the annotation equivalent of calling WriteOptionsBuilder.aliasTypeName(Class, String) / ReadOptionsBuilder.aliasTypeName(Class, String) or adding an entry to the config/aliases.txt configuration file. Equivalent to Jackson's @JsonTypeName.
@IoTypeName("Sensor")
public class SensorReading {
private double value;
private String unit;
}
// Serializes as: {"@type":"Sensor","value":23.5,"unit":"C"}
// Instead of: {"@type":"com.example.SensorReading","value":23.5,"unit":"C"}Note: Programmatic aliasTypeName() takes priority over the annotation. The alias is registered in a reverse lookup map when the class is first scanned, allowing deserialization to resolve the alias back to the class.
Specifies a custom format pattern for fields during serialization and deserialization. The pattern engine is auto-detected based on the pattern string and the field's type. Equivalent to Jackson's @JsonFormat(pattern="...").
Date/time patterns — which formatter is used depends on the field type:
| Field Type | Pattern Engine | Pattern Syntax |
|---|---|---|
LocalDate, LocalTime, LocalDateTime, ZonedDateTime, OffsetDateTime, OffsetTime, Instant |
java.time.format.DateTimeFormatter |
"yyyy-MM-dd", "dd/MM/yyyy HH:mm", etc. |
java.util.Date, java.sql.Date, java.sql.Timestamp |
java.text.SimpleDateFormat |
"MM/dd/yyyy", "yyyy-MM-dd HH:mm:ss", etc. |
Note:
DateTimeFormatterandSimpleDateFormatuse similar but not identical pattern letters. For example,DateTimeFormatterusesuuuufor year (thoughyyyyalso works for common era dates), whileSimpleDateFormatusesyyyy. Consult the Javadoc for each formatter when writing patterns.
public class Event {
@IoFormat("dd/MM/yyyy")
private LocalDate eventDate; // DateTimeFormatter — "23/02/2026"
@IoFormat("yyyy-MM-dd HH:mm")
private LocalDateTime startTime; // DateTimeFormatter — "2026-02-23 14:30"
@IoFormat("MM/dd/yyyy")
private Date legacyDate; // SimpleDateFormat — "02/23/2026"
private LocalDate plain; // Uses default ISO format (no annotation)
}Numeric patterns — two engines available, distinguished by the % character:
| Pattern Style | Detection | Engine | Example Patterns |
|---|---|---|---|
| C-style | Pattern contains % |
String.format() |
"%,d", "%.2f", "%05d", "%x" |
| Java-style | Pattern does NOT contain % |
java.text.DecimalFormat |
"#,###", "$#,##0.00", "0.00" |
Both engines support: int, long, double, float, short, byte (and wrappers), BigDecimal, BigInteger, AtomicInteger, AtomicLong.
Tip: The
%character is the trigger. If your pattern contains%,String.format()is used. Otherwise,DecimalFormatis used. UseString.format()patterns for hex output (%x), zero-padding (%05d), or scientific notation (%e). UseDecimalFormatpatterns for currency ($#,##0.00), percentage with DecimalFormat semantics, or locale-sensitive grouping.
public class Invoice {
// DecimalFormat patterns (no % character)
@IoFormat("#,###")
private int quantity; // DecimalFormat — "1,234"
@IoFormat("$#,##0.00")
private BigDecimal total; // DecimalFormat — "$1,234.56"
@IoFormat("0.00")
private double rate; // DecimalFormat — "3.14"
private int plain; // No annotation — default unquoted JSON number
}public class Report {
// String.format() patterns (contain % character)
@IoFormat("%,d")
private int population; // String.format — "1,234,567"
@IoFormat("%.2f")
private double price; // String.format — "3.14"
@IoFormat("%05d")
private int code; // String.format — "00042"
@IoFormat("%x")
private int color; // String.format — "ff"
}String patterns — String.format() only:
String.format() patterns also work on String fields for padding and alignment:
public class Label {
@IoFormat("%10s")
private String name; // String.format — " hi" (right-aligned)
@IoFormat("%-10s")
private String code; // String.format — "hi " (left-aligned)
}All formatted values are written as quoted JSON strings and parsed back correctly on read. Round-trip precision depends on the pattern — for example, "%.2f" on 3.14159 writes "3.14" and reads back as 3.14.
Field-level annotation that forces $type (or @type) to be written for the annotated field's value and its elements, regardless of the global showTypeInfo setting. This is essential for polymorphic fields when using showTypeInfoNever() or TOON/JSON5 mode (which defaults to never showing type), ensuring that concrete types survive round-trip serialization.
static class Fleet {
@IoShowType
List<Vehicle> vehicles; // Each element gets @type even with showTypeInfoNever()
@IoShowType
Vehicle primary; // The value gets @type even with showTypeInfoNever()
@IoShowType
Vehicle[] spares; // Each array element gets @type
@IoShowType
Map<String, Vehicle> named; // Each map value gets @type
String name; // No annotation — follows global showTypeInfo setting
}When to use: Place @IoShowType on fields declared as an interface or abstract/parent class that hold polymorphic values (subclass instances). Without this annotation, showTypeInfoNever() would strip all type metadata, making it impossible to reconstruct the correct concrete types on read.
Supported field types:
- Plain fields — the field value gets
@type - Collections (
List,Set, etc.) — each element gets@type - Arrays (
Vehicle[]) — each element gets@type - Maps — each map value gets
@type
Interaction with showTypeInfo modes:
showTypeInfoNever()—@IoShowTypeoverrides, forcing type emissionshowTypeInfoMinimal()/showTypeInfoMinimalPlus()—@IoShowTypeis redundant but harmless (type is already shown for polymorphic mismatches)showTypeInfoAlways()—@IoShowTypeis redundant (type is always shown)- JSON5 mode —
@IoShowTypeoverrides the defaultshowTypeInfoNever()behavior - TOON format —
@IoShowTypeforces$typeemission even when type metadata is disabled
Jackson fallback: If @IoShowType is not present, json-io checks for Jackson's @JsonTypeInfo on the field as a synonym. No additional Jackson configuration is needed — the mere presence of @JsonTypeInfo on a field triggers forced type emission.
These annotations allow a class to absorb unrecognized JSON fields during deserialization and emit extra fields during serialization — without requiring a global MissingFieldHandler.
@IoAnySetter marks a method that receives each unrecognized field name and value:
public class FlexibleConfig {
private String name;
private Map<String, Object> extras = new LinkedHashMap<>();
@IoAnySetter
public void handleUnknown(String key, Object value) {
extras.put(key, value);
}
}Contract: Non-static instance method with exactly 2 parameters (String fieldName, Object value).
@IoAnyGetter marks a method that returns a Map<String, Object> of extra fields to include during serialization:
public class FlexibleConfig {
private String name;
private Map<String, Object> extras = new LinkedHashMap<>();
@IoAnyGetter
public Map<String, Object> getExtras() {
return extras;
}
}Contract: Non-static, no-arg instance method returning Map (or any Map subtype).
Typical usage — combine both annotations for round-trip support:
public class FlexibleConfig {
private String name;
private int version;
private Map<String, Object> extras = new LinkedHashMap<>();
@IoAnySetter
public void handleUnknown(String key, Object value) {
extras.put(key, value);
}
@IoAnyGetter
public Map<String, Object> getExtras() {
return extras;
}
}Behavior notes:
- Extra fields from
@IoAnyGetterare written after regular declared fields @IoAnySettertakes priority over the globalMissingFieldHandlerconfigured viaReadOptionsBuilder- Null values in the
@IoAnyGettermap respect theskipNullFieldssetting - If the
@IoAnyGettermethod returnsnullor an empty map, no extra fields are written
Equivalent to Jackson's @JsonAnySetter / @JsonAnyGetter.
Annotations can be combined on the same field or class:
@IoPropertyOrder({"id", "username"})
@IoIgnoreProperties({"password"})
@IoNaming(IoNaming.Strategy.SNAKE_CASE)
public class UserProfile {
private long id;
@IoProperty("username")
@IoAlias({"user_name", "login"})
private String name; // Writes as "username"; reads "username", "user_name", or "login"
private String password; // Excluded by class-level annotation
@IoInclude(IoInclude.Include.NON_NULL)
private String bio; // Omitted when null
@IoDeserialize(as = LinkedList.class)
private List<String> tags; // Always deserialized as LinkedList
}If your classes already use Jackson annotations, json-io will honor them automatically — no code changes needed. The following Jackson annotations are supported:
| Jackson Annotation | json-io Equivalent | Effect |
|---|---|---|
@JsonProperty("name") |
@IoProperty("name") |
Renames field in JSON |
@JsonIgnore |
@IoIgnore |
Excludes field |
@JsonIgnoreProperties({"a","b"}) |
@IoIgnoreProperties({"a","b"}) |
Class-level field exclusion |
@JsonIncludeProperties({"a","b"}) |
@IoIncludeProperties({"a","b"}) |
Class-level field whitelist |
@JsonAlias({"alt1","alt2"}) |
@IoAlias({"alt1","alt2"}) |
Accept alternate names on read |
@JsonPropertyOrder({"x","y"}) |
@IoPropertyOrder({"x","y"}) |
Control field order on write |
@JsonInclude(Include.NON_NULL) |
@IoInclude(Include.NON_NULL) |
Per-field null skipping |
@JsonCreator |
@IoCreator |
Custom deserialization constructor/factory |
@JsonValue |
@IoValue |
Single-value serialization |
@JsonNaming(SnakeCaseStrategy.class) |
@IoNaming(Strategy.SNAKE_CASE) |
Class-level naming strategy |
@JsonIgnoreType |
@IoIgnoreType |
Exclude all fields of this type |
@JsonTypeInfo(defaultImpl=...) |
@IoTypeInfo(...) |
Default concrete type hint |
@JsonDeserialize(as=...) |
@IoDeserialize(as=...) |
Forced deserialization type override |
@JsonGetter("fieldName") |
@IoGetter("fieldName") |
Custom getter method for serialization |
@JsonSetter("fieldName") |
@IoSetter("fieldName") |
Custom setter method for deserialization |
@JsonTypeName("ShortName") |
@IoTypeName("ShortName") |
Type alias for @type in JSON |
@JsonFormat(pattern="...") |
@IoFormat("pattern") |
Per-field format pattern (String.format, DecimalFormat, DateTimeFormatter, or SimpleDateFormat) |
@JsonTypeInfo (on field) |
@IoShowType |
Force $type (or @type) emission on field regardless of global showTypeInfo setting |
Jackson's jackson-annotations JAR (~75KB) is commonly already on the classpath in Spring applications. json-io detects annotations via Class.forName() at startup — there is no compile-time dependency. Some annotations (@JsonNaming, @JsonDeserialize) live in jackson-databind and are detected independently.
Sometimes you will run into a class that does not want to serialize. On the read-side, this can be a class that does
not want to be instantiated easily. A class that has private constructors, constructor with many difficult to supply
arguments, etc. There are unlimited Java classes 'out-there' that json-io has never seen. It can instantiate many classes, and
resorts to a lot of "tricks" to make that happen. As of version 4.56.0 the library itself is compiled with the -parameters flag,
allowing json-io to match JSON fields directly to constructor parameter names when your classes are also compiled with this flag.
This greatly reduces the need for custom factories when classes have accessible constructors with named arguments. However, if a
particular class is not instantiating, add a ClassFactory (one that you write, which subclasses this interface) and associate
it to the class you want to instantiate. See examples for how to do this.
ClassFactory // Create a class that implements this interface
JsonClassWriter // Create a class that implements this interfaceYour ClassFactory class is called after the JSON is parsed and json-io is converting all the Maps to
Java instances. Your factory class is passed the JsonObject (a Map) with the fields and values from the JSON so that
you can create your class and populate it at the same time. Use the Resolver to load complex fields
of your class (Non-primitives, Object[]'s, typed arrays, Lists, Maps), making things easy - you only have to worry about
the primitives in your class (see the examples below for how to 'tee up' the Resolver to load the sub-graph for
you.)
The code examples below show how to write a ClassFactory and JSonClassWriter. There is a JUnit test case in the example
that illustrates how to associate your ClassFactory and JsonClassWriter to particular
classes. The WriteOptions Reference and ReadOptions Reference
have lots of additional information for how to register your factory classes with the ReadOptionsBuilder and WriteOptionsBuilder.
- Primitive fields
- Primitive and non-primitive fields (sub-graph)
- Primitive, array, type array, List, Map
When creating custom writers, use the WriterContext semantic API for cleaner, safer code. The API provides methods that handle quote escaping, comma management, and proper JSON formatting automatically.
Basic Pattern:
class MyWriter implements JsonClassWriter {
public void write(Object obj, boolean showType, Writer output, WriterContext context) throws IOException {
MyClass instance = (MyClass) obj;
// First field: no leading comma
context.writeFieldName("fieldName");
context.writeValue(instance.getFieldValue());
// Subsequent fields: automatic comma handling
context.writeStringField("name", instance.getName());
context.writeNumberField("count", instance.getCount());
context.writeObjectField("data", instance.getData());
}
}Key Methods:
writeFieldName(name)- Writes field name with colon (no comma):"name":writeValue(value)- Writes any value with automatic type detection and escapingwriteStringField(name, value)- Complete string field with comma:,"name":"value"writeNumberField(name, number)- Complete number field with comma:,"count":42writeBooleanField(name, bool)- Complete boolean field with comma:,"active":truewriteObjectField(name, obj)- Complete object field with full serialization:,"data":{...}writeArrayFieldStart(name)- Field name with opening bracket:,"items":[writeObjectFieldStart(name)- Field name with opening brace:,"config":{
Why First Field is Different:
Custom writers are called inside the object that JsonWriter has already opened with {. The first field should NOT have a leading comma:
{
"fieldName": "value", // ← First field (no comma)
"name": "John", // ← Subsequent fields (comma)
"count": 42
}Complete Example:
// From CustomJsonSubObjectsTest.java
static class PersonWriter implements JsonClassWriter {
public void write(Object o, boolean showType, Writer output, WriterContext context) throws IOException {
Person p = (Person) o;
// First field: no leading comma
context.writeFieldName("first");
context.writeValue(p.firstName);
// Subsequent fields: include leading comma
context.writeStringField("last", p.lastName);
context.writeStringField("phone", p.phoneNumber);
context.writeStringField("dob", p.dob.toString());
// Complex types: automatic serialization with cycles/references
context.writeObjectField("kids", p.kids); // Array
context.writeObjectField("pets", p.pets); // List
context.writeObjectField("items", p.items); // Map
}
}Benefits:
- ✅ Automatic quote escaping (no manual
\"handling) - ✅ Automatic comma management (no "boolean first" pattern)
- ✅ Type-safe methods for primitives (no manual formatting)
- ✅ Full support for complex types (cycles, references, @id/@ref)
- ✅ Cleaner, more maintainable code
When creating custom readers, use the Resolver convenience API for cleaner, safer code. The API provides methods that handle type conversion and complex object deserialization automatically.
Basic Pattern:
class MyFactory implements ClassFactory {
public Object newInstance(Class<?> c, JsonObject jsonObj, Resolver resolver) {
MyClass instance = new MyClass();
// Read primitives using convenience methods (automatic type conversion)
instance.name = resolver.readString(jsonObj, "name");
instance.count = resolver.readInt(jsonObj, "count");
instance.price = resolver.readDouble(jsonObj, "price");
instance.active = resolver.readBoolean(jsonObj, "active");
// Read complex types (automatic deserialization with cycles/references)
instance.data = resolver.readObject(jsonObj, "data", DataClass.class);
instance.items = resolver.readList(jsonObj, "items");
instance.config = resolver.readMap(jsonObj, "config");
return instance;
}
}Key Methods:
Primitive Types:
readString(jsonObj, fieldName)- Read string field with automatic conversionreadInt(jsonObj, fieldName)- Read int field with automatic conversionreadLong(jsonObj, fieldName)- Read long field with automatic conversionreadFloat(jsonObj, fieldName)- Read float field with automatic conversionreadDouble(jsonObj, fieldName)- Read double field with automatic conversionreadBoolean(jsonObj, fieldName)- Read boolean field with automatic conversion
Complex Types:
readObject(jsonObj, fieldName, type)- Read and fully deserialize an objectreadArray(jsonObj, fieldName, arrayType)- Read and deserialize a typed array (e.g.,String[].class)readList(jsonObj, fieldName)- Read and deserialize a ListreadMap(jsonObj, fieldName)- Read and deserialize a Map
Complete Example:
// From CustomJsonSubObjectsTest.java
static class PersonFactory implements ClassFactory {
public Object newInstance(Class<?> c, JsonObject jsonObj, Resolver resolver) {
Person person = new Person();
// Read primitives with automatic type conversion
person.firstName = resolver.readString(jsonObj, "first");
person.lastName = resolver.readString(jsonObj, "last");
person.phoneNumber = resolver.readString(jsonObj, "phone");
person.dob = resolver.readObject(jsonObj, "dob", OffsetDateTime.class);
// Read complex types with full deserialization
person.kids = resolver.readArray(jsonObj, "kids", TestObjectKid[].class);
person.friends = resolver.readArray(jsonObj, "friends", Object[].class);
person.pets = resolver.readList(jsonObj, "pets");
person.items = resolver.readMap(jsonObj, "items");
return person;
}
}Benefits:
- ✅ No manual Map casting or instanceof checks
- ✅ Automatic type conversion via Converter
- ✅ Full support for complex types (cycles, references, @id/@ref)
- ✅ Cleaner, more maintainable code
- Used during writing and reading
- Primarily for shortening class names in JSON output
- Example:
java.math.BigInteger = BigInteger - Lightweight, just changes the string representation
- Doesn't affect class loading or behavior
- Annotation equivalent:
@IoTypeName("ShortName")on a class
- Used during class instantiation (
Resolver) - Changes actual class used for instantiation
- Example:
java.util.RegularEnumSet = java.util.EnumSet - More invasive as it affects the actual type created
- Should be used sparingly, only when:
- Handling internal implementation classes (like
RegularEnumSet) - Managing backward compatibility with older serialized forms
- Dealing with JDK implementation details that shouldn't leak into JSON
- Handling internal implementation classes (like
- Used during object instantiation
- Controls how instances are created and populated
- Most flexible and powerful mechanism
- Proper place for custom instantiation logic
- Examples:
EnumSetFactory,CollectionFactory, etc. - Annotation equivalent:
@IoClassFactory(MyFactory.class)on a class
- Used for special serialization/deserialization logic
- Can completely override normal processing
- Most complex but most powerful
- Strongly recommended: Use
ClassFactoryinstead of aCustomReaderas it creates and loads. - Annotation equivalents:
@IoCustomReader(MyReader.class),@IoCustomWriter(MyWriter.class)on a class
Included is a small Javascript utility (jsonUtil.js in the root folder) that will take a JSON output
stream created by the JSON writer and substitute all @refs for the actual pointed to object. It's a one-line
call - resolveRefs(json). This will substitute @ref tags in the JSON for the actual pointed-to (@id) object.
Even though json-io is great for Java / Javascript serialization, here are some other uses for it:
Many projects use JsonIo to write an object to JSON, then read it in, cloning the original object graph:
Employee emp;
// emp obtained from somewhere...
Employee deepCopy = (Employee) JsonIo.deepCopy(emp, null, null); // ReadOptions, WriteOptions can be nullInstead of System.out.println() debugging, call JsonIo.toJson(obj, writeOptions) and dump the JSON
string out. That will give you the full referenceable graph dump in JSON. Use the prettyPrint feature of WriteOptions
to make the JSON more human-readable.
json-io includes an extensive type conversion system powered by the java-util library.
This allows you to convert between nearly any Java types during deserialization. Run the JsonIo.main() method to see
the complete list of supported conversions:
// See all supported conversions
java -cp your-classpath com.cedarsoftware.io.JsonIoLoggingConfig applies a consistent console format for java.util.logging.
Call LoggingConfig.init() once during application startup. You may supply a
custom timestamp pattern via LoggingConfig.init("yyyy/MM/dd HH:mm:ss") or the
system property ju.log.dateFormat.
json-io uses java.util.logging.Logger (JUL) internally so as to bring in no depencies to other libraries except java-util. Most applications prefer frameworks like SLF4J, Logback or Log4j 2. You can bridge JUL to your chosen framework so that logs from this library integrate with the rest of your application.
All steps below are application-scoped—set them up once during your application's initialization.
Optional: Using JUL directly with consistent formatting
If you are not bridging to another framework, call LoggingConfig.init() early in your application's startup. This configures JUL's ConsoleHandler with a formatted pattern. Pass a custom pattern via LoggingConfig.init("yyyy/MM/dd HH:mm:ss") or set the system property ju.log.dateFormat.
// Example initialization
public static void main(String[] args) {
LoggingConfig.init();
// ... application startup
}You may also start the JVM with
java -Dju.log.dateFormat="HH:mm:ss.SSS" -jar your-app.jarTo route JUL messages to a different framework, add the appropriate bridge dependency and perform a one-time initialization.
Add jul-to-slf4j to your build and install the bridge:
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jul-to-slf4j</artifactId>
<version>2.0.7</version>
</dependency>import org.slf4j.bridge.SLF4JBridgeHandler;
public class MainApplication {
public static void main(String[] args) {
SLF4JBridgeHandler.removeHandlersForRootLogger();
SLF4JBridgeHandler.install();
}
}Add log4j-jul and set the java.util.logging.manager system property:
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-jul</artifactId>
<version>2.20.0</version>
</dependency>java -Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
-jar your-app.jarOnce configured, JUL output flows through your framework's configuration.
json-io uses several system properties to control behavior and optimize performance. Most are handled automatically, but understanding them can be helpful for troubleshooting or advanced usage.
Used by: com.cedarsoftware.io.reflect.Injector class
Purpose: Automatically detected to determine the optimal field injection strategy
Values: Automatically set by JVM
Behavior:
- JDK 8-16: Uses
Field.set()for final fields andMethodHandlefor regular fields - JDK 17+: Uses
VarHandlefor improved performance and module system compatibility
This is handled automatically by json-io and requires no user configuration. The library adapts its internal field injection mechanisms based on the detected JDK version for optimal performance and compatibility.
When running json-io's test suite, the following system properties are automatically set to ensure consistent behavior across different environments:
user.timezone=America/New_York: Ensures consistent date/time handlinguser.language=en: Standardizes locale-dependent behavioruser.region=US: Sets region for locale consistencyuser.country=US: Sets country for locale consistency
These properties are set automatically during testing via Maven and generally don't affect runtime usage of json-io in applications.
Used by: LoggingConfig.init() method from java-util dependency
Purpose: Customizes timestamp format for java.util.logging output
Default: Standard timestamp format
Example:
java -Dju.log.dateFormat="yyyy/MM/dd HH:mm:ss" -jar your-app.jarOr programmatically:
LoggingConfig.init("yyyy/MM/dd HH:mm:ss");Used by: Maven Surefire plugin
Purpose: Run specific test classes or patterns
Examples:
# Run a specific test class
mvn test -Dtest=SecurityTest
# Run tests matching a pattern
mvn test -Dtest="*EnumSet*"
# Run performance tests
mvn test -Dtest=JsonPerformanceTestjson-io does not use any environment variables for configuration. All behavior is controlled through Java APIs (ReadOptions/WriteOptions) or system properties as documented above.