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?
// ...
}
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);
}
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.