Skip to content

Self Describing Types

Petrus Pradella edited this page Jul 6, 2026 · 2 revisions

Self-Describing Types

A custom type can carry its own config codec, so it round-trips with no central registration. Because EveryConfig is Jackson-first, the shared mapper discovers the type's codec and applies it in every context — a solo value, a POJO field, or a collection element.

Jackson-native (recommended)

A type that declares @JsonValue (write) + @JsonCreator (read) already self-describes — these are honored by the shared mapper unchanged.

class Coord {
    final int x, y;
    Coord(int x, int y) { this.x = x; this.y = y; }

    @JsonValue
    String encode() { return x + ":" + y; }              // -> the string "3:4"

    @JsonCreator
    static Coord of(String s) {
        String[] p = s.split(":");
        return new Coord(Integer.parseInt(p[0]), Integer.parseInt(p[1]));
    }
}

cfg.setValue("spawn", new Coord(3, 4));   // stored as "3:4" — solo, as a field, or one-per-line in a list
Coord back = cfg.getValue("spawn", Coord.class);

A type that serializes to an object works the same way. A @JsonValue that returns a Map<String, Object> (paired with a @JsonCreator that takes a Map) stores the value in a structured form; an immutable POJO rebuilt via a @JsonCreator constructor with @JsonProperty args is another shape. Any Jackson mechanism (@JsonSerialize(using=) / @JsonDeserialize(using=), custom modules on your own mapper) applies too.

Enums

A plain enum always serializes by name() (stable and hand-editable). An enum that declares a @JsonValue keeps its custom form instead:

enum Transport {
    NIO("nio"), EPOLL("epoll");
    final String code;
    Transport(String code) { this.code = code; }

    @JsonValue String code()               { return code; }      // stored as "nio" / "epoll"
    @JsonCreator static Transport of(String c) { /* look up by code */ }
}

A type you cannot edit — a mixin

Can't add annotations to the type (a third-party class)? Declare the same @JsonValue / @JsonCreator on a Jackson mixin and register it on the codec's mapper — the mapper applies it as if the annotations were on the class itself.

abstract class CoordMixin {
    @JsonValue                          abstract String encode();
    @JsonCreator static Coord of(String s) { return Coord.parse(s); }
}

ObjectMapper mapper = new ObjectMapper();
mapper.addMixIn(Coord.class, CoordMixin.class);   // now Coord self-describes through this mapper
Config cfg = Config.open(path, new YamlCodec(mapper));

Gotchas

A codec is required. A self-describing type needs a codec-backed Config (Config.open / Config.inMemory) — the mapper is what applies @JsonValue/@JsonCreator. A bare new Config() has no mapper and takes only native values (scalars, Map, List, JsonNode).

Untyped reads see the raw form. getValue(path) without a type token returns the raw string/map — a self-describing codec reaches the typed reads (getValue(path, Type), getList(path, Type)) and binding, not the untyped dynamic read (there is no type to reconstruct from).

A different form inside a collection

Want a type that is rich as a solo value but compact inside a list? That is a distinct, opt-in mechanism — see the compact element form in @KeyIndex Collections.

→ See also Entity Binding · Annotations · The Dynamic API

Clone this wiki locally