May 24, 2022

Model Results/States with Java 17's Records

I often want to represent a set of possible results or states with different possible values. For example a processing result:

  • A successful full result

  • And expect error with some error code

  • An unexpected error with some exception

  • Some other rare edge case I need special handling for.

This was so far kind of clunky to express in Java. You had generally two options:

Clunky Solution: One Class With Optional Fields

Create a class that represents all possible results, but with many 'optional' fields:

public class ProcessResult {
    public enum ResultType{
        Success,
        ProcessFailed,
        UnexpectedError,
        PermissionError
    }

    public final ResultType type;
    public final int exitCode; // Used for all
    public final byte[] successData; // only used for Success
    public final Exception unexpectedEx; // only used for UnexpectedError
    public final String unixUserUsed; // only used for PermissionError

    public ProcessResult(ResultType type, int exitCode, byte[] successData, Exception unexpectedEx, String unixUserUsed) {
        this.type = type;
        this.exitCode = exitCode;
        this.successData = successData;
        this.unexpectedEx = unexpectedEx;
        this.unixUserUsed = unixUserUsed;
    }
}

The main drawback is that its hard for the reader (or you next year) to follow what fields are used in what error case. When using the class, what parameters do I have to fill out? Hard to see from the code without checking the documentation. Yes, extra static factory methods can help, but that adds more boiler plate to write.

var success = new ProcessResult(ProcessResult.ResultType.Success, 0, "OK".getBytes(StandardCharsets.UTF_8), null, null);
var failed = new ProcessResult(ProcessResult.ResultType.ProcessFailed, 1, null, null, null);
var exception = new ProcessResult(ProcessResult.ResultType.UnexpectedError, -1, null, null, null);

Further, when consuming the result, you have the same issue again. You need to always check the documentation/source code on what field combinations are valid:

ProcessResult result = null;
switch (result.type){
    case Success:
        // what fields are relevant for this case?
    case ProcessFailed:
        // what fields are relevant for this case?
    // ...
}
All in One Tool
Figure 1. All in One Tool

Clarify with a Class Per Type

The other solution is to create a class for each state. It is boilerplate intense. In return this way communicates what data is available for a given result:

public abstract class ProcessResult {
    public final int exitCode;
    protected ProcessResult(int exitCode){
        this.exitCode = exitCode;
    }

    public static class Success extends ProcessResult{
        public final byte[] successData;
        public Success(byte[] successData) {
            super(0);
            this.successData = successData;
        }
    }

    public static class ProcessFailed extends ProcessResult{

        protected ProcessFailed(int exitCode) {
            super(exitCode);
        }
    }

    public static class UnexpectedError extends ProcessResult{
        public final Exception exception;

        public UnexpectedError(Exception exception) {
            super(-1);
            this.exception = exception;
        }
    }

    public static class PermissionError extends ProcessResult{
        public final  String unixUserUsed;

        public PermissionError(int exitCode, String unixUserUsed) {
            super(exitCode);
            this.unixUserUsed = unixUserUsed;
        }
    }

}

With this, it is very clear what fields need to be filled out. You are guided to the right things:

var success = new ProcessResult.Success("OK".getBytes(StandardCharsets.UTF_8));
var failed = new ProcessResult.ProcessFailed(1);
var exception = new ProcessResult.UnexpectedError(new Exception("OMG"));

Matching these states is decent if you use pattern matching in Java 17:

ProcessResult result = null;
if(result instanceof ProcessResult.Success success){
    // the right values for a success result are available right here
    System.out.println("Success" + success.successData);
} else if(result instanceof ProcessResult.ProcessFailed failed){
    System.out.println("Process failed, exit code:" + failed.exitCode);
}
// ... more specific matches
else{
    System.out.println("Other result type: " + result.exitCode);
}
Separated Classes
Figure 2. Separated Classes

Use Pattern Match Switch (Preview in Java 17/18)

If you are willing to enable Java Preview features, you can get the pattern match in Switch statements, making the matching even more smooth. Enable the preview feature with the javac argument --enable-preview --release 17 / --enable-preview --release 18.

With that you get nice switch statements with the pattern match:

switch (result){
    case ProcessResult.Success success ->
        // the right values for a success result are available right here
        System.out.println("Success" + success.successData);
    case ProcessResult.ProcessFailed failed ->
        // the right values for a success result are available right here
        System.out.println("Success" + failed.exitCode);
    // ... more specific matches
    default ->
        System.out.println("Other result type: " + result.exitCode);
}

Reduce the Boilerplate with Records and Sealed Interface

We improve on this approach by using records. Java records can not inherit from classes, but can inherit interfaces. So, we can create an interface and a record for each sub-result type. Note the 'sealed' keyword: This ensures the interface is only implemented in the current file or white-listed subclasses. It gives the reader the confidence that there are no other implementations around.

public sealed interface ProcessResult {
    // Note, this is either 'implemented' the record field, or manually
    int exitCode();

    record Success(byte[] successData) implements ProcessResult{
        public int exitCode(){
            return 0;
        }
    }

    record ProcessFailed(int exitCode) implements ProcessResult{}

    record UnexpectedError(Exception exception) implements ProcessResult{
        public int exitCode(){
            return -1;
        }
    }

    record PermissionError(int exitCode, String unixUserUsed) implements ProcessResult{
    }
}

Note, that in the pattern match you now need to use the accessor methods of the record instead of the fields. Otherwise, everything works great. Plus the records will implement hashCode, equals, and a decent toString implementation.

Summary

With Java 17 or newer, it became pleasant to create a class/record for representing different result types or different states. If you have different result types or states which hold different data, then I recommend to take advantage of it.

Tags: Scala Java Development