Tuesday, July 22, 2025

Making YAML & Java Records Work Seamlessly — With a Custom SnakeYAML Constructor

 (Originally published in LinkedIn)

A while back, I shared a trick for deserializing Java records from YAML using SnakeYAML. That approach worked fine for my specific case — but as my project evolved to include nested records, collections, and optional fields, I realized the standard constructor wasn’t enough.

That’s when I decided to write my own SnakeYAML constructor that understands records — deeply.


❓ Why a Custom Constructor?

SnakeYAML’s default Constructor is designed around JavaBeans (setters and no-args constructors), not immutable records.

Problems arise when:

  • You use nested records
  • Your fields are generics like List<Answer>
  • You define optional values like Optional<Integer>
  • You expect automatic type conversion, e.g., "5" to int


✅ Enter RecordConstructor

You can find the whole code here

Here’s how it works:

public class RecordConstructor extends Constructor {
    public RecordConstructor(Class<?> rootType) {
        super(rootType);
    }

    @Override
    protected Object constructObject(Node node) {
        Class<?> targetType = node.getType();
        if (targetType.isRecord() && node instanceof MappingNode mappingNode) {
            return constructRecord(targetType, mappingNode);
        }
        return super.constructObject(node);
    }

    private Object constructRecord(Class<?> recordClass, MappingNode node) {
        Map<String, Object> values = new LinkedHashMap<>();
        for (NodeTuple tuple : node.getValue()) {
            Object key = constructObject(tuple.getKeyNode());
            Object value = constructObject(tuple.getValueNode());
            values.put((String) key, value);
        }
        return RecordUtils.instantiateRecord(recordClass, values);
    }
}

💡 This overrides SnakeYAML's default object construction. If the YAML node maps to a record class, we collect key-value pairs into a map and hand it off to RecordUtils.


🧠 How RecordUtils Instantiates Records

The magic happens in instantiateRecord():

class RecordUtils {

    public static <T> T instantiateRecord(Class<T> recordClass, Map<String, Object> values) {
        try {
            RecordComponent[] components = recordClass.getRecordComponents();
            Class<?>[] paramTypes = Arrays.stream(components)
                .map(RecordComponent::getType)
                .toArray(Class[]::new);

            Object[] args = new Object[components.length];
            for (int i = 0; i < components.length; i++) {
                RecordComponent rc = components[i];
                args[i] = convertValue(rc.getType(), values.get(rc.getName()), rc.getGenericType());
            }

            Constructor<T> ctor = recordClass.getDeclaredConstructor(paramTypes);
            return ctor.newInstance(args);
        } catch (ReflectiveOperationException e) {
            throw new RuntimeException("Failed to instantiate record", e);
        }
    }

    private static Object convertValue(Class<?> type, Object value, Type genericType) {
        if (value == null && type.isPrimitive()) throw new IllegalArgumentException("Missing primitive");
        if (type == Optional.class && genericType instanceof ParameterizedType pt) {
            Object inner = value == null ? null : convertValue((Class<?>) pt.getActualTypeArguments()[0], value, pt.getActualTypeArguments()[0]);
            return Optional.ofNullable(inner);
        }
        if (type.isRecord() && value instanceof Map map)
            return instantiateRecord(type, (Map<String, Object>) map);

        if ((type == List.class || type == Set.class) && value instanceof Collection<?> col && genericType instanceof ParameterizedType pt) {
            Class<?> elementType = (Class<?>) pt.getActualTypeArguments()[0];
            Collection<Object> result = type == List.class ? new ArrayList<>() : new HashSet<>();
            for (Object o : col) result.add(convertValue(elementType, o, elementType));
            return result;
        }

        if (type == Map.class && value instanceof Map<?, ?> map && genericType instanceof ParameterizedType pt) {
            Class<?> keyType = (Class<?>) pt.getActualTypeArguments()[0];
            Class<?> valType = (Class<?>) pt.getActualTypeArguments()[1];
            Map<Object, Object> result = new LinkedHashMap<>();
            for (var entry : map.entrySet()) {
                Object k = convertSimple(keyType, entry.getKey());
                Object v = convertValue(valType, entry.getValue(), valType);
                result.put(k, v);
            }
            return result;
        }

        return convertSimple(type, value);
    }

    private static Object convertSimple(Class<?> type, Object value) {
        if (value == null) return null;
        if (type.isInstance(value)) return value;
        if (type == String.class) return value.toString();
        if (type == int.class || type == Integer.class) return Integer.parseInt(value.toString());
        if (type == boolean.class || type == Boolean.class) return Boolean.parseBoolean(value.toString());
        if (type.isEnum()) return Enum.valueOf((Class<Enum>) type, value.toString());
        throw new IllegalArgumentException("Cannot convert " + value + " to " + type);
    }
}

  1. Get record components — These are the fields declared in your record (name, type, etc.), in constructor order.
  2. Resolve constructor parameter types — We build the parameter type array from the record components.
  3. Match values by name — For each component, we retrieve its value from the YAML map using the component’s name.
  4. Type coercion — We convert string/numeric values to the required types (e.g., "5" → int), recursively handle nested records, and unpack collections and optionals.
  5. Find the canonical constructor — Using reflection:

Java guarantees that every record has a canonical constructor, where parameters appear in the same order as the record components. This makes deserialization reliable without additional mapping metadata.


🧪 Example Use

record Answer(String text, int score) {}
record Question(String title, List<Answer> answers) {}

YAML:

title: "Workout survey"
answers:
  - text: "Daily"
    score: "5"
  - text: "Sometimes"
    score: 2

Usage:

First, add to your project's POM:

<dependency>
  <groupId>org.yaml.snakeyaml</groupId>
  <artifactId>yamlrecords</artifactId>
  <version>1.0.4</version>
</dependency>
Yaml yaml = new Yaml(new RecordConstructor(Question.class));
Question q = yaml.load(inputStream);

Works out of the box — with coercion, nesting, and type conversion.


🔁 Type Handling Examples

record User(String name, Optional<Integer> age) {}
record Survey(String title, List<Question> questions) {}

YAML:

name: Alied
age: "42"
title: "Workout survey"
questions:
  - text: "Daily"
    score: "5"
  - text: "Sometimes"
    score: 2

It handles:

  • Optional<T>: wraps presence or absence
  • List<T>, Set<T>: maps collections recursively
  • Map<K,V>: even with type coercion on keys
  • Enum values: by matching string literals

How to use it:

An example of how to add to your project. Currently it's only published in Github's repository. In the future I'll probably upload to MavenCentral.

https://github.com/The-Java-Druid/genetic-fit-test/commit/3e78aa4b2a772b5d5735238d1edda527e162026f#r162311253


🔚 Summary

✅ Clean, type-safe deserialization ✅ Supports nested structures, collections, and optionals ✅ Built entirely with Java reflection ✅ Plays nicely with modern record-based modeling

This solution started as a utility for my own apps — but it’s proven generic and reusable enough to be useful in other projects.

📦 you can use it from here, and maybe I'll even submit it as a SnakeYAML extension.


💬 What do you think? Are you using records with YAML or JSON? Would this help in your stack?


No comments:

Post a Comment

Your Friendly Guide to Mastering the Linux Kernel with kernel-update

If you have ever explored the inner workings of Linux, you probably know that the kernel is the heart of your operating system. While most u...