Javactic is a Java port of Scalactic's Or and Every mechanism. Javactic is based on the Javaslang functional library for Java 8+. This documentation is mostly ported from here.
Or and Every
The Or and Every
types of Javactic allow you to represent errors as an "alternate return value" (like Either) and to optionally
accumulate errors. Or represents a value that is one of two possible types, with one type being
"good" (a value wrapped in an instance of Good ) and the other "bad" (a value wrapped in an
instance
of Bad ).
The motivation for Or
Or is very similar to Javaslang's Either type (which is right biased), but allows for additional
accumulation of bad values. Scala's Either is somewhat different in that it treats both its Left
and Right alternatives in an identical manner and requires the use of explicit projections when manipulated.
To illustrate all this, imagine you want to create instances of this Person class from user input strings:
class Person {
private final String name;
private final int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "Person(" + name + "," + age + ")";
}
}
You might write a method that parses the name from user input string and returns an
Option<String>
: None if the string is empty or blank, else the trimmed string wrapped in a Some :
Option<String> parseName(String name) {
String trimmed = name.trim();
return (trimmed.isEmpty()) ? Option.none() : Option.of(trimmed);
}
You might also write a method that parses the age from user input string and returns an Option<Int>
: None if either the string is not a valid integer or it is a negative integer, else the string
converted to an integer wrapped in a Some :
Option<Integer> parseAge(String input) {
try {
int age = Integer.parseInt(input.trim());
return (age >= 0) ? Option.of(age) : Option.none();
} catch (NumberFormatException e) {
return Option.none();
}
}
With these building blocks you could write a method that parses name and age input strings and returns either
a Person
, wrapped in a Some , or None if either the name or age, or both, was invalid:
Option<Person> parsePerson(String inputName, String inputAge) {
return parseName(inputName).flatMap(name ->
parseAge(inputAge).map(age -> new Person(name, age))
);
}
Here are some examples of invoking parsePerson :
parsePerson("Tyler Durden", "29")
// Some(Person(Tyler Durden,29))
parsePerson("Tyler Durden", "")
// None
parsePerson("Tyler Durden", "-29")
// None
parsePerson("", "")
// None
Now imagine you want to give an error message back if the user's input is invalid. You might rewrite the
parsing
methods to return an Either instead. In this case, the desired result is a valid name or age,
which
by convention should be placed on the right of the Either . The left will be a string error
message.
Here's the new parseName function, which returns an Either<String, String> :
Either<String,String> parseName(String input) {
String trimmed = input.trim();
return (!trimmed.isEmpty())
? Either.right(trimmed)
: Either.left("'" + input + "' is not a valid name");
}
And here's the new parseAge function, which returns an Either<String, Int> :
Either<String, Integer> parseAge(String input) {
try {
int age = Integer.parseInt(input.trim());
return (age >= 0) ? Either.right(age) : Either.left("'" + age + "' is not a valid age");
} catch (NumberFormatException e) {
return Either.left("'" + input + "' is not a valid integer");
}
}
The new parsePerson method will return an Either<String, Person> :
Either<String, Person> parsePerson(String inputName, String inputAge) {
return parseName(inputName)
.flatMap(name -> parseAge(inputAge)
.map(age -> new Person(name, age)));
}
Given this implementation, the parsePerson method
will now short-circuit at the first sign of trouble (as it did when we used an Option ), but you
now
get the first error message returned in a Left . Here are some examples:
parsePerson("Tyler Durden", "29")
// Right(Person(Tyler Durden,29))
parsePerson("Tyler Durden", "")
// Left('' is not a valid integer)
parsePerson("Tyler Durden", "-29")
// Left('-29' is not a valid age)
parsePerson("", "")
// Left('' is not a valid name)
An alternative to Either
As explained above, at first glance Or is very similar to Javaslang's Either.
One difference to note with Or is that the Good
alternative is on the left, Bad on the right.
Here's how the parseName method might be written using an Or , where the error type
is also a String:
Or<String, String> parseName(String input) {
String trimmed = input.trim();
return (!trimmed.isEmpty())
? Good.of(trimmed)
: Bad.ofString("'{}' is not a valid name", input);
}
Here's how the parseAge method might be written:
Or<Integer, String> parseAge(String input) {
try {
int age = Integer.parseInt(input.trim());
return (age >= 0) ? Good.of(age) : Bad.ofString("'{}' is not a valid age", age);
} catch (NumberFormatException e) {
return Bad.ofString("'{}' is not a valid integer", input);
}
}
Given these implementations, here's how you'd write the parsePerson method:
Or<Person, String> parsePerson(String inputName, String inputAge) {
return parseName(inputName)
.flatMap(name -> parseAge(inputAge)
.map(age -> new Person(name, age))
);
}
This is pretty much the same code as for Either, including the short circuiting at the first sign of a
Bad. The invocations of parsePerson produce the exact same results.
Accumulating errors with Or
The main difference between Or and Either is that Or enables you to
accumulate errors very naturally if the Bad type is an Every. An Every
is similar to a Seq
in that it contains ordered elements, but different in that it cannot be empty. An
Every
is either a One (containing one and only one element), or a Many (containing two
or more elements).
Note: an Or whose Bad type is an Every, or one of its subtypes, is
called an "accumulating Or."
To rewrite the previous example so that errors can be accumulated, you need first to return an
Every
as the Bad type. Here's how you'd change the parseName method:
Or<String, One<String>> parseName(String input) {
String trimmed = input.trim();
return (!trimmed.isEmpty())
? Good.of(trimmed)
: Bad.ofOneString("'{}' is not a valid name", input);
}
Because parseName will either return a valid name String wrapped in a
Good
, or one error message, wrapped in a Bad , you would write the Bad type as One<String>
. The same is true for parseAge :
Or<Integer, One<String>> parseAge(String input) {
try {
int age = Integer.parseInt(input.trim());
return (age >= 0) ? Good.of(age) : Bad.ofOneString("'{}' is not a valid age", age);
} catch (NumberFormatException e) {
return Bad.ofOneString("'{}' is not a valid integer", input);
}
}
Because a for expression short-circuits on the first Bad encountered, you'll need to use a
different
approach to write the parsePerson method. This can be done with the withGood method from
class Accumulation:
Or<Person, Every<String>> parsePerson(String inputName, String inputAge) {
Or<String, One<String>> name = parseName(inputName);
Or<Integer, One<String>> age = parseAge(inputAge);
return Accumulation.withGood(name, age, (n, a) -> new Person(n, a));
}
Class Accumulation offers overloaded withGood methods that take 1 to 8 accumulating
Or
s, plus a function taking the same number of corresponding Good values. In this example, if both
name
and age are Good s, the withGood method will pass the good name String
and
age int to the Person constructor, and return the resulting Person object wrapped in
a Good
. If either name and age, or both, are Bad , withGood will return the accumulated
errors
in a Bad .
The result of parsePerson , if Bad , will therefore contain either one or two error
messages, i.e., the result will either be a One or a Many . As a result, the result
type
of parsePerson must be Or<Person, Every<String>> . Regardless of
whether a Bad result contains one or two error messages, it will contain every error message.
Here's
some invocations of this accumulating version of parsePerson:
parsePerson("Tyler Durden", "29")
// Good(Person(Tyler Durden,29))
parsePerson("Tyler Durden", "")
// Bad(One('' is not a valid integer))
parsePerson("Tyler Durden", "-29")
// Bad(One('-29' is not a valid age))
parsePerson("", "")
// Bad(Many('' is not a valid name, '' is not a valid integer))
Note that in the last example, the Bad contains an error message for both name and age.
Working with Ors
Ors can be created using static constructors on either the Or interface or the
implementing Good and Bad classes. Constructors on the Or interface
will
return the value as an Or , whereas constructors on specific types will return specific types:
Or.good("good"); // Or<String, Object>
Or.bad("bad"); // Or<Object, String>
Good.of("good"); // Good<String, Object>
Bad.of("bad"); // Bad<Object, String>
Note that since Or has two types, but each of its two subtypes only takes a value of one or the
other
type, the Java compiler will infer Object for the unspecified type. This can be changed by either
assigning the value to a variable with a more specific type or by giving explicit type arguments:
Or<String, Integer> good = Or.good("good"); // Or<String, Integer>
Or<Integer, String> bad = Or.bad("bad"); // Or<Integer, String>
Or.<String, Integer>good("good"); // Or<String, Integer>
Or.<Integer, String>bad("bad"); // Or<Integer, String>
A specific type like Bad can be widened back to an Or with the asOr
method
Bad.of("bad").asOr(); // Or<Object, String>
You can transform an existing Or into an accumulating one with the accumulating()
method. There are also factory methods for creating accumulating Or s directly:
Or<String, One<String>> acc = Bad.<String,String>of("bad").accumulating();
Bad<String, One<String>> ofOne = Bad.ofOne("bad");
Bad<String, One<String>> ofOneString = Bad.ofOneString("error with value {}", 12);
Working with Everys
The previous examples demonstrate constructing a one-element Every with a factory method in the
One
companion object. You can similarly create an Every that contains more than one using a Many
factory method. Here are some examples:
One.of(1);
Many.of(1, 3);
Many.of(1, 2, 3);
You can also construct an Every by passing one or more elements to the Every.of
factory
method:
Every.of(1);
Every.of(1, 2);
Every.of(1, 2, 3);
Every does not extend Seq or Traversable interfaces because these
require
that implementations may be empty. For example, if you invoke tail() on a Seq that
contains just one element, you'll get an empty Seq
On the other hand, many useful methods exist on Seq that when invoked on a non-empty
Seq
are guaranteed to not result in an empty Seq . For convenience, Every defines a
method
corresponding to every such Seq method. Here are some examples:
Many.of(1, 2, 3).map(i -> i + 1); // Many(2, 3, 4)
One.of(1).map(i -> i + 1); // One(2)
Every.of(1, 2, 3).containsSlice(Every.of(2, 3)); // true
Every.of(1, 2, 3).containsSlice(Every.of(3, 4)); // false
Every.of(-1, -2, 3, 4, 5).minBy(i -> Math.abs(i)); // -1
Every does not currently define any methods corresponding to Seq methods that could
result in an empty Seq . However, you can convert an Every to a Seq and
get hold of these methods:
Every.of(1, 2, 3).toSeq().filter(i -> i < 10); // Vector(1, 2, 3)
Every.of(1, 2, 3).toSeq().filter(i -> i > 10); // Vector()
Other ways to accumulate errors
The Accumulation class also enables other ways of accumulating errors.
Using combined
If you have a collection of accumulating Or s, for example, you can combine them into one Or
using combined:
List<Or<Integer, One<String>>> list = List.ofAll(parseAge("29"), parseAge("30"), parseAge("31"));
Accumulation.combined(list, List.collector()); // Good(List(29, 30, 31))
List<Or<Integer, One<String>>> list2 = List.ofAll(parseAge("29"), parseAge("-30"), parseAge("31"));
Accumulation.combined(list2, List.collector()); // Bad(One("-30" is not a valid age))
List<Or<Integer, One<String>>> list3 = List.ofAll(parseAge("29"), parseAge("-30"), parseAge("-31"));
Accumulation.combined(list3, List.collector());
// Bad(Many("-30" is not a valid age, "-31" is not a valid age))
Using validatedBy
If you have a collection of values and a function that transforms that type of value into an
accumulating Or, you can validate the values using the function using validatedBy:
List<String> list = List.ofAll("29", "30", "31");
Accumulation.validatedBy(list, this::parseAge, List.collector()); // Good(List(29, 30, 31))
List<String> list2 = List.ofAll("29", "-30", "31");
Accumulation.validatedBy(list2, this::parseAge, List.collector()); // Bad(One("-30" is not a valid age))
List<String> list3 = List.ofAll("29", "-30", "-31");
Accumulation.validatedBy(list3, this::parseAge, List.collector());
// Bad(Many("-30" is not a valid age, "-31" is not a valid age))
Using zip
You can also zip two accumulating Or s together. If both are Good ,
you'll
get a Good tuple containing both original Good values. Otherwise, you'll get a
Bad
containing every error message. Here are some examples:
Or<Tuple2<String, Integer>, Every<String>> zip = Accumulation.zip(parseName("Dude"), parseAge("21"));
// Good((Dude,21))
Accumulation.zip(parseName("Dude"), parseAge("-21"));
// Bad(One("-21" is not a valid age))
Accumulation.zip(parseName(""), parseAge("-21"));
// Bad(Many("" is not a valid name, "-21" is not a valid age))
Using when
In addition, given an accumulating Or , you can pass one or more validation functions to when on
the
Or to submit that Or to further scrutiny. A validation function accepts a
Good
type and returns a Validation<E> , where E is the type in the Every in the
Bad
type. For an Or<Integer, One<String> , for example the validation function type would be Integer
-> Validation<String> . Here are a few examples:
Validation<String> isRound(int i) {
return (i % 10 == 0) ? Pass.instance() : Fail.of(i + " was not a round number");
}
Validation<String> isDivBy3(int i) {
return (i % 3 == 0) ? Pass.instance() : Fail.of(i + " was not divisible by 3");
}
If the Or on which you call when is already Bad , you get the same (Bad)
Or
back, because no Good value exists to pass to the validation functions:
Or<Integer, Every<String>> when = Accumulation.when(parseAge("-30"), this::isRound, this::isDivBy3);
// Bad(One("-30" is not a valid age))
If the Or on which you call when is Good , and also passes all the validation
functions
(i.e., they all return Pass ), you again get the same Or back, but this time, a
Good
one:
Accumulation.when(parseAge("30"), this::isRound, this::isDivBy3);
// Good(30)
If one or more of the validation functions fails, however, you'll get a Bad back containing every
error:
Accumulation.when(parseAge("33"), this::isRound, this::isDivBy3);
// Bad(One(33 was not a round number))
Accumulation.when(parseAge("20"), this::isRound, this::isDivBy3);
// Bad(One(20 was not divisible by 3))
Accumulation.when(parseAge("31"), this::isRound, this::isDivBy3);
// Bad(Many(31 was not a round number, 31 was not divisible by 3))
OrFuture
The OrFuture and OrPromise are not part of the original Scalactic library. So why one more future implementation? The problem with futures as they are traditionally implemented is that failures are always represented by an exception, when it's perfectly ok to think that an asynchronous operation can fail in a way that is not exceptional. The OrFuture represents an asynchronous version of the Or type, and it doesn't complete with a success or a failure but with an instance of Or. Let's see a few examples:
Working with OrFutures
An OrFuture can be created with one of the static .of() methods:
OrFuture<String, String> f = OrFuture.of(() -> Or.good("good value"));
OrFuture<String, String> f =
OrFuture.of(Executors.newSingleThreadExecutor(), () -> Or.good("good value"));
OrFutures can also be created through a FutureFactory that automatically handles uncaught
exceptions and transforms them to Bad values through the provided exception translator:
FutureFactory<String> factory = FutureFactory.of(Throwable::getMessage);
OrFuture<String, String> future =
factory.newFuture(() -> throw new RuntimeException("runtime exception"));
future.onComplete(System.out::println);
// Bad(runtime exception)
It is advised to create all OrFutures through a factory, exceptions that leak out of the asynchronous computation will be handled by the executor in its ThreadGroup's UncaughtExceptionHandler and might not be visible anywhere. You can think of the exception translator as a much less obtrusive way of handling an ExecutionException.
Here are the same parsing examples we saw for Or's implemented asynchronously using OrFutures:
Function<Throwable, String> converter = Throwable::getMessage;
FutureFactory<String> ff = FutureFactory.of(converter);
OrFuture<String, String> parseNameAsync(String input) {
return ff.newFuture(() -> parseName(input));
}
OrFuture<Integer, String> parseAgeAsync(String input) {
return ff.newFuture(() -> parseAge(input));
}
OrFuture<Person, String> parsePersonAsync(String inputName, String inputAge) {
return parseNameAsync(inputName)
.flatMap(name -> parseAgeAsync(inputAge)
.map(age -> new Person(name, age)));
}
Running these methods will provide the same results:
OrFuture<Person, String> asyncOr = parsePersonAsync("Tyler Durden", "29");
asyncOr.onComplete(System.out::println);
// Good(Person(Tyler Durden,29))
asyncOr = parsePersonAsync("Tyler Durden", "");
asyncOr.onComplete(System.out::println);
// Bad('' is not a valid integer)
asyncOr = parsePersonAsync("Tyler Durden", "-29");
asyncOr.onComplete(System.out::println);
// Bad('-29' is not a valid age)
asyncOr = parsePersonAsync("", "");
asyncOr.onComplete(System.out::println);
// Bad('' is not a valid name)
Accumulating errors with OrFutures
Like we saw with vanilla Ors, the parsePersonAsync only returned the error for the first failing
argument. This can be corrected by implementing accumulating versions of the parse methods and using the helper
methods on the OrFuture interface to accumulate potential errors:
Function<Throwable, One<String>> converter = throwable -> One.of(throwable.getMessage());
FutureFactory<One<String>> ff = FutureFactory.of(converter);
OrFuture<String, One<String>> parseNameAsync(String input) {
return ff.newFuture(() -> parseName(input));
}
OrFuture<Integer, One<String>> parseAgeAsync(String input) {
return ff.newFuture(() -> parseAge(input));
}
OrFuture<Person, Every<String>> parsePersonAsync(String inputName, String inputAge) {
OrFuture<String, One<String>> name = parseNameAsync(inputName);
OrFuture<Integer, One<String>> age = parseAgeAsync(inputAge);
return OrFuture.withGood(name, age, Person::new);
}
Methods combined, validatedBy & zip also exist as with the
Accumulation class for Ors.