Teyru

JSON and the Gson Compatibility Layer

The Gson-shaped tree API, plus object binding generated by the compiler.

Teyru's JSON comes in two layers: lib/10_json.teyru is Gson's tree API (pure Teyru, no compiler cooperation needed), and lib/19_gson.teyru adds the compiler's object binding.

Tree API (lib/10)

JsonElement, JsonObject, JsonArray, JsonPrimitive, JsonNull, JsonParser.parseString, Gson, GsonBuilder, JsonSyntaxException, with behavior aligned to Gson 2.10, including several details that are easy to get wrong:

  • JsonNull extends JsonElement rather than JsonPrimitive.
  • Parsed numbers keep the original literal, so 1e5 prints back as 1e5 and does not become 100000.0.
  • Escaping follows Gson's substitution table: " and \ are escaped, the five short forms \b \t \n \f \r, everything else below 0x20 uses \u00xx (lowercase hex), / is not escaped, and non-ASCII is emitted as UTF-8 rather than \u escapes, with the sole exceptions of U+2028/U+2029; <, >, &, =, ' are escaped only in the htmlSafe writing (the default new Gson()), while JsonElement.toString() does not escape them.
  • Empty objects and empty arrays stay on one line when pretty printed ({}, []).

Differences from Gson (deliberate):

  • JsonParser.parseString is strict (RFC 8259). The {a:1}, 'single quotes', 01, +1, .5, 1., NaN, [1,] that Gson's lenient mode accepts are all rejected and throw JsonSyntaxException; an empty document is rejected too, and trailing extra data is always rejected.
  • entrySet() returns List<JsonMember> and keySet() returns List<String> (Gson gives a Set); JsonMember's getKey/getValue correspond to Map.Entry.
  • Numbers only go up to long; there is no getAsBigDecimal/getAsBigInteger.
  • Numeric accessors throw IllegalArgumentException rather than NumberFormatException (the former is the latter's superclass, so code that catches the superclass is unaffected; a catch that names NumberFormatException will not catch them).

Object binding (lib/19 plus the compiler)

Gson uses reflection to bind objects to JSON. Here the compiler does it instead:

import teyru.*

class Address {
  String city
  String zip
}

class Person {
  String name
  int age
  @SerializedName("home_address") Address address
}

Gson gson = new Gson()
Person p = gson.fromJson(json, Person.class)   // no cast needed
String out = gson.toJson(p)

When the compiler sees fromJson(s, Person.class), it generates __teyruJsonRead(JsonElement) and __teyruJsonWrite() on Person, and rewrites the call into them. Therefore:

  • No cast needed: Gson's <T> T fromJson(String, Class<T>) cannot be expressed in Teyru (Class is not generic), but after the rewrite the return type is the target class itself.
  • A field type with no mapping is a compile error (TY-TYP-0110; prelude classes such as List/Map are TY-TYP-0108), rather than an IllegalArgumentException thrown at runtime from deep inside a reflection adapter.
  • Binding is generated per call site: if it is not used, it is not generated, and the field list is the one at generation time (Lombok-generated members included).

Supported field types: String, boolean/byte/short/char/int/long/float/ double and their boxed classes, enum (written as the constant name, read back by comparing names, the same as Gson; when the name does not match Gson leaves null, while this throws JsonParseException), other bindable classes (recursive), Object (preserves the original tree: a JsonObject that was read in is written back as-is), and containers: the List/Set/Map/Deque interfaces and their implementations, and arrays, with the object elements inside them bound as well (the element type is written into the field descriptor by the compiler and Field.getElementType() reads it; reflection itself still has no generic types). Like Gson, char is written as a single-character string and read back from a string too.

GsonBuilder's knobs (serializeNulls, setPrettyPrinting, setLenient, the five setFieldNamingPolicy policies, excludeFieldsWithModifiers, excludeFieldsWithoutExposeAnnotation, setVersion), the filtering and renaming of @Expose/@Since/@Until/@SerializedName, the JsonSerializer/JsonDeserializer adapters (registerTypeAdapter/registerTypeHierarchyAdapter/@JsonAdapter) and the streaming JsonReader/JsonWriter are all there (t166_gson_builder, t167_gson_annotations, t168_gson_adapters; containers are t157_json_collections and t165_json_container_elements).

What is still not supported: element types are recorded one level deep (the inner element of a List<List<Person>> is read from the document's shape, and an object or array inside it is refused by name; the same goes for an element declared Object), an array whose component is ? super or an unbounded type variable has no class to build an array from (the member is refused by name), @SerializedName's alternate (several names are written as repeated annotations: the first is the one written out, the rest are also accepted on input), and @JsonAdapter names the adapter class as a string.

Runtime fallback table

For a reference whose static type is Object (gson.toJson(someObject)), the compiler cannot see through, so this JsonBinding table is used: every class that has had a binding generated registers a reader/writer in its own static initializer, and lookup uses the object's own class — which is exactly the answer Gson's reflection gives.

Object can hold any class in the program, so as soon as such a call site appears, the compiler generates a binding for every class declared in the program (except interfaces, abstract classes, and classes that have no no-arg constructor and cannot be read from JSON; those that cannot be generated are not registered, and a runtime query for them reports that there is no binding). A program without this call site still generates bindings only for the classes actually used.

  • tests/programs/t93_json.teyru — tree API: escaping, numbers, pretty print, round-trip.
  • tests/programs/t101_gson.teyru — object binding: nested classes, @SerializedName, missing fields, runtime fallback table.
  • tests/programs/t140_json_binding_edges.teyru — edges: char round-trip, the tree inside an Object field, classes passed only through Object, enum and unknown constant names.

On this page