Magnus' blog


Multiple errors at a time in Java

Multiple errors are not supported by default in Java, so you have to write some extra code to handle them. The extra code usually turns into boilerplate that gets copied around the codebase. This is especially troublesome because Java does not make it easy to support generic, type-safe error-combination functionality. This post demonstrates how you can standardize throwing or returning multiple errors with a Result<T, List<E>> in a type-safe way.

The example code is also available on Github.

The problem

An example is the registration of a User that contains fields for name, dateOfBirth, and email. This can be represented in a class like the following:

public class User {
    public String name;
    public LocalDate dateOfBirth;
    public String email;

    public User(String name, LocalDate dateOfBirth, String email) {
        this.name = validatedName(name);
        this.dateOfBirth = validatedDateOfBirth(dateOfBirth);
        this.email = validatedEmail(email);
    }

    private static String validatedName(String name) {
        if (name == null) {
            throw new IllegalArgumentException("Name cannot be null");
        }

        final var trimmedName = name.trim();
        if (trimmedName.isBlank()) {
            throw new IllegalArgumentException("Name cannot be blank");
        }

        return trimmedName;
    }

    private static LocalDate validatedDateOfBirth(LocalDate dateOfBirth) {
        if (dateOfBirth == null) {
            throw new IllegalArgumentException("Date of birth cannot be null");
        }

        if (dateOfBirth.isBefore(LocalDate.of(1900, 1, 1))) {
            throw new IllegalArgumentException("Date of birth cannot be before 1900-01-01");
        }

        if (dateOfBirth.isAfter(LocalDate.now().minusYears(18))) {
            throw new IllegalArgumentException("User must be at least 18 years old");
        }

        return dateOfBirth;
    }

    private static String validatedEmail(String email) {
        if (email == null) {
            throw new IllegalArgumentException("Email cannot be null");
        }

        final var trimmedEmail = email.trim();
        if (trimmedEmail.isBlank()) {
            throw new IllegalArgumentException("Email cannot be blank");
        }

        if (!trimmedEmail.contains("@")) {
            throw new IllegalArgumentException("Email must contain an '@' character");
        }

        return trimmedEmail;
    }
}

Each of the three fields is validated, and an exception will be thrown if the input is invalid. However, only a single exception can be reached at a time. This means the user will only ever see one error at a time. First they will get an exception for the name, which they have to fix before the date of birth validation runs, and only after that is valid will the email validation run.

This is terrible for UX, since the user will think they are close to completing the form but will continuously encounter new errors. A better approach is to ensure the user can see all errors discoverable from the current state. For an empty form, the system would find an error for each field and display them all at once. The next two sections will show how to achieve this with Exceptions and Result<T, E>s.

Throwing multiple exceptions

An aggregator for exceptions can be implemented quite easily in a non-type-safe way using a Varargs function:

public interface VarFunction<T> {
    T apply(Object... args);
}

public static class AggregatedException extends Exception {
    public AggregatedException(List<Exception> exceptions) {
        exceptions.forEach(this::addSuppressed);
    }
}

@SafeVarargs
public static <T> T exceptionAggregator(
        VarFunction<T> function,
        Supplier<Object>... argSuppliers
) throws AggregatedException {
    final var args = new Object[argSuppliers.length];
    final var exceptions = new ArrayList<Exception>();
    for (int i = 0; i < argSuppliers.length; i++) {
        final var argSupplier = argSuppliers[i];
        try {
            args[i] = argSupplier.get();
        } catch (final Exception e) {
            exceptions.add(e);
        }
    }
    if (!exceptions.isEmpty()) {
        throw new AggregatedException(exceptions);
    }
    return function.apply(args);
}

This uses a Varargs object as the arguments, which means it is not type-safe. To call it you will have to manually cast the objects to their concrete types. An overload can be placed on top of this to allow for type safety. This is done by implementing a Function interface that takes generic arguments and returns a specific type, and then a new function that combines those generic arguments.

Here are the code examples for 2 and 3 arguments (the repository contains examples for up to 8 arguments):

@FunctionalInterface
public interface Function2<T1, T2, T> {
    T apply(T1 t1, T2 t2);
}

@SuppressWarnings("unchecked")
public static <T1, T2, T> T exceptionAggregator(
        Function2<T1, T2, T> function,
        Supplier<T1> supplier1,
        Supplier<T2> supplier2
) throws AggregatedException {
    final VarFunction<T> varFunction = (args) -> function.apply((T1)args[0], (T2)args[1]);
    return exceptionAggregator(
        varFunction,
        (Supplier<Object>) supplier1,
        (Supplier<Object>) supplier2
    );
}

@FunctionalInterface
public interface Function3<T1, T2, T3, R> {
    R apply(T1 t1, T2 t2, T3 t3);
}

@SuppressWarnings("unchecked")
public static <T1, T2, T3, T> T exceptionAggregator(
        Function3<T1, T2, T3, T> function,
        Supplier<T1> supplier1,
        Supplier<T2> supplier2,
        Supplier<T3> supplier3
) throws AggregatedException {
    final VarFunction<T> varFunction = (args) -> function.apply((T1)args[0], (T2)args[1], (T3)args[2]);
    return exceptionAggregator(
        varFunction,
        (Supplier<Object>) supplier1,
        (Supplier<Object>) supplier2,
        (Supplier<Object>) supplier3
    );
}

This function can be used by a static factory method in the earlier example to aggregate the exceptions:

public static User of(String name, LocalDate dateOfBirth, String email) throws CombineThrow.AggregatedException {
    return CombineThrow.exceptionAggregator(
            User::new,
            () -> validatedName(name),
            () -> validatedDateOfBirth(dateOfBirth),
            () -> validatedEmail(email)
    );
}

Now the exception will contain all 3 suppressed exceptions, and they can be mapped and shown to the user as 3 errors at the same time. However, I think this approach can be further improved by using Result<T, E>.

Multiple errors with Result<T, E>

My library for Result<T, E> allows for expressing errors in the return type instead of throwing Exceptions. To use this, the exceptions have to be replaced with results. For each of the 3 validation methods, this is done by simply replacing the return value and throw with Result.ok and Result.err:

private static Result<String, String> validatedName(String name) {
    if (name == null) {
        return Result.err("Name cannot be null");
    }

    final var trimmedName = name.trim();
    if (trimmedName.isBlank()) {
        return Result.err("Name cannot be blank");
    }

    return Result.ok(trimmedName);
}

private static Result<LocalDate, String> validatedDateOfBirth(LocalDate dateOfBirth) {
    if (dateOfBirth == null) {
        return Result.err("Date of birth cannot be null");
    }

    if (dateOfBirth.isBefore(LocalDate.of(1900, 1, 1))) {
        return Result.err("Date of birth cannot be before 1900-01-01");
    }

    if (dateOfBirth.isAfter(LocalDate.now().minusYears(18))) {
        return Result.err("User must be at least 18 years old");
    }

    return Result.ok(dateOfBirth);
}

private static Result<String, String> validatedEmail(String email) {
    if (email == null) {
        return Result.err("Email cannot be null");
    }

    final var trimmedEmail = email.trim();
    if (trimmedEmail.isBlank()) {
        return Result.err("Email cannot be blank");
    }

    if (!trimmedEmail.contains("@")) {
        return Result.err("Email must contain an '@' character");
    }

    return Result.ok(trimmedEmail);
}

As with the exception example, a static factory method can be used to combine the Results:

public static Result<User, List<String>> of(String name, LocalDate dateOfBirth, String email) {
    return CombineResult.combine(
            User::new,
            validatedName(name),
            validatedDateOfBirth(dateOfBirth),
            validatedEmail(email)
    );
}

This is implemented in much the same way as the exception aggregator:

public interface VarFunction<T> {
    T apply(Object... args);
}

@SafeVarargs
public static <T, E> Result<T, List<E>> combine(
        VarFunction<T> function,
        Result<Object, E>... resultArgs
) {
    final var args = new Object[resultArgs.length];
    final var errors = new ArrayList<E>();
    for (int i = 0; i < resultArgs.length; i++) {
        final var arg = resultArgs[i];
        int finalI = i;
        arg.consume(object -> args[finalI] = object)
                .consumeError(errors::add);
    }
    if (!errors.isEmpty()) {
        return Result.err(errors);
    }
    return Result.ok(function.apply(args));
}

@SuppressWarnings("unchecked")
public static <T1, T2, T3, T, E> Result<T, List<E>> combine(
        Function3<T1, T2, T3, T> function,
        Result<T1, E> result1,
        Result<T2, E> result2,
        Result<T3, E> result3
) {
    final VarFunction<T> varFunction = (args) -> function.apply((T1)args[0], (T2)args[1], (T3)args[2]);
    return combine(varFunction, (Result<Object, E>) result1, (Result<Object, E>) result2, (Result<Object, E>) result3);
}

Now type safety is provided to the caller of the combine function, and it easily allows multiple errors to be returned.

This little example and experiment is something I have used for a while in real projects, though never with an explicit error type. It is something I expect to add to the Result<T, E> library shortly.

Conclusion

These examples show how to return multiple errors in Java. This can be used to display all relevant errors to a user at once, which helps improve the user experience of an application.

The example code is also available on Github.

View next or previous post: