Errors and Error Handling in Ballerina — Part II — Error Subtypes

Maryam Ziyad
Ballerina Swan Lake Tech Blog
12 min readJun 5, 2023
Photo by Christin Hume on Unsplash

This post is based on Ballerina Swan Lake Update 5.

In a previous article we looked into the basics of errors and error handling in Ballerina, including the error type, creating error values, panic, trap, check, checkpanic, on fail, etc.

In this post we will be looking at defining custom error types and several related concepts.

The generic error type represents all error values. Depending on the use-case, you may want

  • control over the fields that have to be included in the detail mapping
  • to be more precise in declaring what kinds of errors your function(s) would return
  • to have error values and types have nominal behaviour

Error type descriptors and module type definitions

Types in Ballerina are described using type descriptors. Error type descriptors are used to describe error types.

An error type descriptor takes the following form.

type-parameter := <type-descriptor> 
error-type-descriptor := error [type-parameter]

It is the error keyword followed by an optional type specified within angle brackets. If the type parameter is specified, it defines the structure of the detail mapping (referred to as detail type here onward) for error values that belong to the error type described by the error type descriptor.

E.g.,

error<record {| int code; |}>

error<Detail>

error<map<string>>

As mentioned in the previous article, the detail mapping always belongs to map<value:Cloneable>. Therefore, the type specified as the detail type has to be a subtype of map<value:Cloneable>.

error is just the error type descriptor without the detail type parameter.

error

A module type definition in Ballerina binds an identifier (the name) with a specific type descriptor. Module type definitions allow referring to error (and other) type descriptors by name instead of specifying the error type descriptor in-line.

type E1 error;

type E2 error<record {| int code; |}>;

type Detail record {| int code; |};
type E3 error<Detail>;

type E4 error<map<string>>;

Let’s look at each type definition:

  • E1 is just an alias for the builtin error
  • E2 is bound to an error type descriptor which requires/allows a single integer detail field named code
  • E3 is the same as E2, but instead of a record type descriptor for the detail, it uses a type reference, referring to another type definition Detail, that defines the same record as the one in E2
  • E4 is bound to an error type descriptor with a map type descriptor as the detail, which requires all the detail fields to be strings

Module type definitions are also important with distinct error types (as we will see in the section on distinct error types).

Defining an error type with a specific detail type

By explicitly specifying a detail type in an error type descriptor, we define the fields that need to be specified when constructing the error. Similarly, when accessing the fields we have more knowledge about required fields, optional fields, etc. that will be available in the detail mapping.

Let’s look at a concrete example. Let’s define a type named InvalidUsernameError, where we use a record type InvalidUsernameErrorDetails as the detail type.

type InvalidUsernameErrorDetails record {|
string reason;
int code = 1001;
|};


type InvalidUsernameError error<InvalidUsernameErrorDetails>;

We have used a type definition to bind InvalidUsernameError to an error type descriptor error<InvalidUsernameErrorDetails>.

Since InvalidUsernameErrorDetails is a closed record with exactly two fields, only these two fields can be specified when constructing an error of type InvalidUsernameError. Since code has a default value, it is not mandatory to specify a value for code when constructing the error, it defaults to the default value if unspecified. However, since reason is a required field with no default value, it is mandatory to specify a value for it when constructing the error value.

Constructing an error value of a specific type

An error constructor expression can be used with an error type descriptor or by using an error type reference (i.e., referring to a module type definition bound to an error type descriptor) to construct an error value of the specific type. This can be done in one of two ways.

Using the specific type as the expected type

The first approach is to specify either the error type descriptor or the user-defined type as the expected type to the error constructor.

type InvalidUsernameErrorDetails record {|
string reason;
int code = 1001;
|};

type InvalidUsernameError error<InvalidUsernameErrorDetails>;

error<record {| int length; |}> e1 = error("invalid length", length = 5);

InvalidUsernameError e2 = error("invalid username", reason = "contains spaces");

For e1 we have used an error type descriptor as the expected type, whereas with e2 we’ve used a reference type.

Either way, the constraints related to the detail type will be enforced in the error constructor. For example, if the required fields were not specified or if the types were incompatible, there would be compilation errors.

// compilation error: required `length` detail field is not specified
error<record {| int length; |}> e1 = error("invalid length");

// compilation error: detail field `code`, if specified, has to be `int`
InvalidUsernameError e2 = error("invalid username",
reason = "contains spaces",
code = "1011");

Using the specific type in the error constructor expression

Alternatively, with an error type reference, the type reference can be used in the error constructor expression itself.

error InvalidUsernameError("invalid username", reason = "contains spaces")

Using the `detail` lang lib function with a specific error type

Since from the error type, we can identify the detail type, the static type of the detail lang lib function will also be the more specific type. Moreover, since an error value and therefore its components are all immutable, the static type of the detail call on an error of type error<T> is T & readonly.

import ballerina/io;

type InvalidUsernameErrorDetails record {|
string reason;
int code = 1001;
|};

type InvalidUsernameError error<InvalidUsernameErrorDetails>;

public function main() {
error<record {| int length; |}> e1 = error("invalid length", length = 5);
record {| int length; |} & readonly d1 = e1.detail();
int length = d1.length;
io:println(length); // 5

InvalidUsernameError e2 = error InvalidUsernameError("invalid username",
reason = "contains spaces");
InvalidUsernameErrorDetails {reason, code} = e2.detail();
io:println(reason); // contains spaces
io:println(code); // 1001
}

Structural typing

Ballerina is fundamentally structurally-typed, with nominal typing support for objects and errors with distinct types (explained below).

In the non-distinct case, with structural typing, an error value belongs to an error type error<T> if the detail mapping belongs to T. So with the examples we’ve seen above, by defining type definitions, we only define a name to refer to a specific error type descriptor. The name does not matter in subtyping relationships.

For example, in the following snippet, we do not use InvalidUsernameError in the error constructor. But, when you use the error value in an is check with InvalidUsernameError, it will evaluate to true. This is because the detail mapping in e satisfies the requirements for the detail type of InvalidUsernameErrorInvalidUsernameErrorDetails.

import ballerina/io;

type InvalidUsernameErrorDetails record {|
string reason;
int code = 1001;
|};

type InvalidUsernameError error<InvalidUsernameErrorDetails>;

public function main() {
error<record {| string reason; int code = 1001; |}> e =
error("invalid length", reason = "invalid length", code = 1002);
checkErrorType(e); // true
}

function checkErrorType(error e) {
io:println(e is InvalidUsernameError);
}

Moreover, as mentioned previously, error values are immutable and therefore, the detail mapping value is also immutable. Thus, irrespective of the detail type in the error type used to construct the error value, a runtime type compatibility check will only check if the detail mapping value is compatible with the type, since we know the detail mapping cannot change.

import ballerina/io;

type Detail record {|
string reason;
boolean fatal?;
|};

public function main() {
error<Detail> x = error("invalid username", reason = "contains spaces");
// `true` since the detail mapping has only the `string` `reason` field
io:println(x is error<record {| string reason; |}>); // true

error<Detail> y = error("invalid username", reason = "contains spaces", fatal = true);
// `false` since the detail mapping has the `fatal` field which is not allowed
// in the detail type of `E2`
io:println(y is error<record {| string reason; |}>); // false
}

Distinct errors

However, sometimes, you may require functionality similar to nominal types. For example, where a function may return an error due to a number of different reasons, you may want to take specific action depending on what caused the error.

Let’s consider a simple example. Say we have a json value, which is expected to be a JSON object from which we access a username field (expected to be string), and validate for length and absence of spaces. Assume we want to log different errors if the validation fails due to invalid length or blank spaces vs when the payload as invalid.

Without distinct errors, we could consider some checks based on error messages, specific detail types, etc.

import ballerina/log;

const INVALID_USERNAME = "InvalidUsername";

type InvalidUsernameError error<record {| string message; |}>;

function validateUsername(json registration) returns error? {
// `check` fails if
// - `registration` is not a JSON object or
// - `registration` doesn't have a `username` field
// - the `name` field does not have a `string` value
string username = check registration.username;

if username.length() < 6 {
return error InvalidUsernameError(INVALID_USERNAME,
message = "invalid length");
}

if username.indexOf(" ") !is () {
return error InvalidUsernameError(INVALID_USERNAME,
message = "contains spaces");
}
}

public function main() {
error? res = validateUsername({username: "may"});

if res is error {
if res is InvalidUsernameError {
log:printError("username validation failed");
} else {
log:printError("invalid payload");
}
}
}
$ bal run sample.bal 
time = 2023-05-03T17:46:57.123+05:30 level = ERROR module = "" message = "username validation failed"

However, as we’ve seen already, this can open up room for error with structural typing since any error with a detail mapping that has just a string message field can cause this is check to evaluate to true.

In fact, if you try passing an invalid JSON value for which check registration.username will fail, you’ll notice that the is InvalidUsernameError evaluates to true, since the detail mapping in the error returned by Ballerina also belongs to the detail type of InvalidUsernameError.

public function main() {
error? res = validateUsername({});
io:println(res);

if res is error {
if res is InvalidUsernameError {
log:printError("username validation failed");
} else {
log:printError("invalid payload");
}
}
}
$ bal run sample.bal 
error("{ballerina/lang.map}KeyNotFound",message="key 'username' not found in JSON mapping")
time = 2023-05-03T17:50:30.259+05:30 level = ERROR module = "" message = "username validation failed"

Let us rewrite this with distinct types. A distinct type can be defined using the distinct keyword.

Let us now define a distinct type to represent username validation failures. This distinct type can be used similar to any other error.

import ballerina/log;

const INVALID_USERNAME = "InvalidUsername";

// Note how we have used `distinct`.
type InvalidUsernameError distinct error<record {| string message; |}>;

function validateUsername(json registration) returns error? {
// `check` fails if
// - `registration` is not a JSON object or
// - `registration` doesn't have a `username` field
// - the `name` field does not have a `string` value
string username = check registration.username;

if username.length() < 6 {
return error InvalidUsernameError(INVALID_USERNAME,
message = "invalid length");
}

if username.indexOf(" ") !is () {
return error InvalidUsernameError(INVALID_USERNAME,
message = "contains spaces");
}
}

function validate(json registration) {
error? res = validateUsername(registration);

if res is error {
if res is InvalidUsernameError {
log:printError("username validation failed");
} else {
log:printError("invalid payload");
}
}
}

public function main() {
validate({username: "may"});
validate({});
}
$ bal run sample.bal 
time = 2023-05-03T17:55:47.904+05:30 level = ERROR module = "" message = "username validation failed"
time = 2023-05-03T17:55:47.996+05:30 level = ERROR module = "" message = "invalid payload"

Now, the different messages are logged as expected.

You can also define a distinct subtype of another distinct type using the distinct keyword. For example, let’s say we want to return a distinct error when username validation fails due to invalid length, but we still want it to be a subtype of InvalidUsernameError. Using a module type definition for the distinct InvalidUsernameError error allows us to define a subtype of it.

import ballerina/log;

const INVALID_USERNAME = "InvalidUsername";

type InvalidUsernameError distinct error<record {| string message; |}>;

type InvalidUsernameLengthError distinct InvalidUsernameError;

function validateUsername(json registration) returns error? {
string username = check registration.username;

int length = username.length();
if length < 6 {
// Create an error of the `InvalidUsernameLengthError` type.
return error InvalidUsernameLengthError(INVALID_USERNAME,
message = string `invalid length: ${length}`);
}

if username.indexOf(" ") !is () {
return error InvalidUsernameError(INVALID_USERNAME,
message = "contains spaces");
}
}

function validate(json registration) {
error? res = validateUsername(registration);

if res is error {
if res is InvalidUsernameLengthError {
log:printError("username validation failed due to invalid length");
} else if res is InvalidUsernameError {
log:printError("username validation failed");
} else {
log:printError("invalid payload");
}
}
}

public function main() {
validate({username: "may"});
validate({username: "ma ry am"});
validate({});
}

Note how we can now use the is check against the specific subtype.

$ bal run sample.bal 
time = 2023-05-10T11:11:40.597+05:30 level = ERROR module = "" message = "username validation failed due to invalid length"
time = 2023-05-10T11:11:40.621+05:30 level = ERROR module = "" message = "username validation failed"
time = 2023-05-10T11:11:40.647+05:30 level = ERROR module = "" message = "invalid payload"

Because InvalidUsernameLengthError is a subtype of InvalidUsernameError, we need to check for InvalidUsernameLengthError before checking for InvalidUsernameError, since is InvalidUsernameError will evaluate to true for an InvalidUsernameLengthError error value also.

function validate(json registration) {
error? res = validateUsername(registration);

if res is error {
if res is InvalidUsernameError {
log:printError("username validation failed");
} else if res is InvalidUsernameLengthError {
log:printError("username validation failed due to invalid length");
} else {
log:printError("invalid payload");
}
}
}

Note how with the swap in is checks, the “username validation failed” log is logged for when the length is invalid too.

$ bal run sample.bal 
time = 2023-05-10T11:16:39.511+05:30 level = ERROR module = "" message = "username validation failed"
time = 2023-05-10T11:16:39.529+05:30 level = ERROR module = "" message = "username validation failed"
time = 2023-05-10T11:16:39.554+05:30 level = ERROR module = "" message = "invalid payload"

Distinct types are widely used by library modules to define fine-grained errors.

Error intersections

An intersection type T1&T2 represents the value space of values that belong to both T1 and T2. Error intersections are especially useful when defining subtypes of a distinct error type with additional constraints on the detail mapping or when defining an error type that is a subtype of multiple distinct types. For example, assume the detail type of the distinct InvalidUsernameError error type is an open record with a single string message field as a required field.

type InvalidUsernameError distinct error<record { string message; }>;

Now let’s assume we want to define InvalidUsernameLengthError again as a subtype of InvalidUsernameError, but now we want to include an additional detail field length of type int, in just InvalidUsernameLengthError. In order to achieve this, an error intersection has to be used.

type InvalidUsernameLengthError InvalidUsernameError & error<record { int length; }>;

By the definition of intersection types, an error value e will belong to InvalidUsernameLengthError if and only if it belongs to both InvalidUsernameError and error<record { int length; }>. Therefore, any error value that belongs to InvalidUsernameLengthError will have a detail mapping that belongs to record { string message; int length; }.

import ballerina/log;

const INVALID_USERNAME = "InvalidUsername";

type InvalidUsernameError distinct error<record { string message; }>;

type InvalidUsernameLengthError InvalidUsernameError & error<record { int length; }>;

function validateUsername(json registration) returns error? {
string username = check registration.username;

int length = username.length();
if length < 6 {
// Create an error of the `InvalidUsernameLengthError` type.
return error InvalidUsernameLengthError(INVALID_USERNAME,
message = string `invalid length`,
length = length);
}

if username.indexOf(" ") !is () {
return error InvalidUsernameError(INVALID_USERNAME,
message = "contains spaces");
}
}

function validate(json registration) {
error? res = validateUsername(registration);

if res is error {
if res is InvalidUsernameLengthError {
// Note how we can directly access the `length` field once the type is
// narrowed to `InvalidUsernameLengthError`.
int length = res.detail().length;
log:printError(
string `username validation failed due to invalid length: ${length}`);
} else if res is InvalidUsernameError {
log:printError("username validation failed");
} else {
log:printError("invalid payload");
}
}
}

public function main() {
validate({username: "may"});
validate({username: "ma ry am"});
validate({});
}
$ bal run sample.bal 
time = 2023-05-10T11:59:58.569+05:30 level = ERROR module = "" message = "username validation failed due to invalid length: 3"
time = 2023-05-10T11:59:58.589+05:30 level = ERROR module = "" message = "username validation failed"
time = 2023-05-10T11:59:58.619+05:30 level = ERROR module = "" message = "invalid payload"

Specific return types

With the specific error subtypes, our functions can have more specific return types indicating the possible error values that can be returned by the function.

type InvalidUsernameError distinct error;

type SpacesInUsernameError distinct error;

type InvalidUsernameLengthError InvalidUsernameError & error<record { int length; }>;

function validateUsername(string username) returns SpacesInUsernameError|InvalidUsernameLengthError? {

}

That’s it for the basics of error subtypes. In the next post, we will look at error binding patterns and match patterns.

This article is an updated version of [Ballerina] Error Handling — Part II, which was written based on an older version of Ballerina.

Also published on Stack Overflow.

--

--