Blog
Exceptionless API design
Let's start with the following (REST-)API defined in RSD:
record Person {
id: string;
firstname: string;
lastname: string;
}
error NotFound
error NotAllowed
service Person {
get(id: string): Person
throws NotFound, NotAllowed;
}
The initial Java API I derived from this looked like this:
interface PersonService {
public Person get(String id)
throws NotFoundException, NotAllowedException;
}
I guess this looks like the obvious choice, and I used it in some projects, such as quti, my simple calendar server.
I also started working on JS/TS client generation. The initial API looked like this:
export interface PersonService {
get(id: string): Promise<Person>;
}
But I wasn't really satisfied because the fact that the asynchronous method could throw NotFound and NotAllowed wasn't visible in its signature. You had to read the documentation to know which errors it could throw.
try {
const person = await service.get("1");
// ...
} catch(e) {
// ...
}
I definitely wanted a better solution.
As luck would have it, I had also started looking into Rust again. I liked its error handling so much that I applied the same idea to my JS/TS API.
I finally settled on:
export interface PersonService {
public get(id: string): Promise<Result<Person, NotFoundError | NotAllowedError>>;
}
When using the service, your code looks like this:
const [person, error] = await service.get("1");
if( person ) {
console.log('Person: ', person.firstname, person.lastname);
} else {
if( isNotFoundError(error) ) {
console.warn('Person has not been found');
} else {
console.warn('Not allowed to access');
}
}
To me, the result-type API has the following advantages:
- The type system helps you deal with errors.
- The result is immediately available in the current scope, not just inside the
tryblock.
Having used the JS/TS API in some projects, coming back to my Java API felt like a major step backward. It wasn't as bad as it had been when working in JS/TS, but it still felt limiting, so I decided to apply the Result pattern to Java.
The generated Java API now looks like this:
sealed interface GetError permits NotFoundError, NotAllowedError {}
record NotFoundError implements GetError {
// ...
}
record NotAllowedError implements GetError {
// ...
}
interface PersonService {
public Result<Person, GetError> get(String id);
}
Since modern Java supports pattern matching and destructuring, the API feels really good to use now:
var result = service.get("1");
switch(result) {
case OK(var value) ->
System.out.println("Person: " + value.firstname + " " + value.lastname);
case ERR(NotFoundError error) ->
System.out.println("Person has not been found");
case ERR(NotAllowedError error) ->
System.out.println("Not allowed to access");
}
Or, if you prefer a more functional style:
var result = service.get("1");
result
.onOk( value ->
System.out.println("Person: " + value.firstname + " " + value.lastname) )
.onErr( error -> {
if( error instanceof NotFoundError e ) {
System.out.println("Person has not been found");
} else {
System.out.println("Not allowed to access");
}
})
Compared to TS the only caveat is that Java does not have union types (that's not 100% true because for catch clauses in exceptions they are available) so the GetError interface has to be introduced.
In a ideal world the API would look like this in Java:
record NotFoundError {
// ...
}
record NotAllowedError {
// ...
}
interface PersonService {
public Result<Person, NotFoundError | NotAllowedError> get(String id);
}