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.