Errors and Error Handling in Ballerina — Part I

Maryam Ziyad
Ballerina Swan Lake Tech Blog
15 min readMay 3, 2023
Photo by John Schnobrich on Unsplash

This post is based on Ballerina Swan Lake Update 5

Ballerina does not have exceptions. It has a separate error basic type which is used to indicate errors in a program.

With the separate error type, errors in Ballerina are explicit and pretty much impossible to ignore by design. Any and all error values created belong to a builtin type named error.

Below we’ve written a function that takes a string username and validates it. If the username is of a length less than 6 or if it has spaces we want to consider it as an invalid username, else, the username needs to be considered valid.

We use the builtin error type to indicate an invalid username. For valid usernames, we can just return nil, which in Ballerina represents the absence of any other value. Therefore, we specify error? which is the union of error and nil as the return type. This is the same as error|().

function validateUsername(string username) returns error? {
if username.length() < 6 || username.indexOf(" ") !is () {
return error("invalid username");
}
}

If the username meets the criteria for an invalid username, we create and return an error. If not, nil is returned (implicitly here by falling off the end of the function).

Now wherever this function is called, we can check if the username is valid by checking whether the returned value is an error value or nil.

Error values

An error value has three components:

  • message — a string representing the error message
  • cause — an optional component, which if available is also an error value that can be used to indicate the error that caused the current error
  • detail — an immutable mapping value giving additional details about the error — empty mapping if no additional details are available. The detail mapping is of a type that is a subtype of map<value:Cloneable>.

Constructing error values and accessing the error message, cause, and detail mapping

An error value can be constructed using the error constructor expression.

error("InvalidLength", 
reason = string `invalid length: ${username.length()}`)

Let’s look into the error constructor in detail based on some examples.

error invalidLengthErr = error("InvalidLength", 
length = username.length(),
code = "E1001");

error invalidUsernameErr = error("InvalidUsername",
invalidLengthErr,
code = "E1011");

Here we construct two error values and assign them to variables named invalidLengthErr and invalidUsernameErr.

The first error value is used when constructing the second error. The first argument to an error constructor expression is expected to be a positional argument of type string, which will be the error message. In the examples, they are “InvalidLength” and “InvalidUsername”.

A second positional argument if present is expected to be of a type that is a subtype of type error (i.e., an error value), which will be the cause. In the first example, no cause is specified, whereas in the second example invalidLengthErr is specified as the cause.

The rest of the arguments, if present, are expected to be named arguments representing the fields of the error detail mapping. In both the examples we have detail fields: length and code for invalidLengthErr and just code for invalidUsernameErr.

Given that only the error message is mandatory, we can have an error constructor with just the error message.

error err = error("invalid username");

Since no detail arguments are specified, the detail would be an empty mapping for err.

The message, cause, and detail mapping of an error value can be accessed using the message, cause, and detail functions defined in the error lang library.

import ballerina/io;
import ballerina/lang.value;

public function main() {
// Invalid username for demonstration.
string username = "jo";

// Construct an error with no cause but with two detail fields.
error e1 = error("InvalidLength", length = username.length(),
code = "E1001");

// Access and print the message of `e1`.
string message = e1.message();
io:println(message); // InvalidLength

// Access the cause of `e1`.
error? cause = e1.cause();
// Since a cause wasn't specified, `cause` will be nil.
io:println(cause is ()); // true

// Access the detail mapping of `e1`.
map<value:Cloneable> & readonly detail = e1.detail();

// Print the length of the detail mapping of `e1`.
io:println(detail.length()); // 2

// Access the `code` field in the detail mapping and print it.
// Since from the static type of the detail mapping `
// map<value:Cloneable> & readonly`, the compiler does not
// know if the detail mapping has a field named `code`,
// member access needs to be used.
// The static type of the access is `value:Cloneable & readonly`
// which also includes nil for if the field is not present.
value:Cloneable & readonly code = detail["code"];
io:println(code); // E1001

// Construct an error with a cause but with no detail fields.
error e2 = error("InvalidUsername", e1);

io:println(e2.message()); // InvalidUsername

cause = e2.cause();
// Since `e1` was specified as the cause, `cause` will be `e1``.
io:println(cause); // error("InvalidLength",length=2,code="E1001")
io:println(cause === e1); // true

// Print the length of the detail mapping of `e2`.
// The length will be `0` since no arguments were specified
// for the detail mapping.
io:println(e2.detail().length()); // 0
}

An error value can be used similar to other values — e.g., can be passed as arguments, can be returned from functions, etc.

However, the error type (and an error value) does have some differences that differentiate it from the other types/values:

  • An error value cannot be assigned to a variable of type any (or used where a value belonging to any is expected) — the any type in Ballerina represents all values in Ballerina, except for an error value.
  • An error value cannot be ignored using the wildcard (_).

The following results in compilation errors:

public function main() {
error e = error("error reason", code = 404);

any a = e; // compilation error
_ = e; // compilation error
}

Note that it is an error value that does not belong to any, and therefore is distinguished from other values. But a structure of errors will belong to any.

public function main() {
error[] arr = [error("error reason", code = 404)];

any a = arr; // valid
_ = arr; // valid
}

These together with the fact that values cannot be ignored implicitly in Ballerina help ensure that errors cannot be ignored.

An action or an expression can evaluate to an error value or can cause abrupt termination via panic.

Expressions evaluating to an error value

Various kinds of expressions can evaluate to an error value. For example, calling a function can evaluate to an error value if the function may return an error value. Lax access on JSON can also evaluate to an error. The static type of these expressions would therefore include error.

function fn(json j) returns error? {
json|error name = j.id;

error? validationRes = validateUsername(name);
}

function validateUsername(json|error username) returns error? {
}

User code reports errors via functions that return errors.

Functions returning error values

A function that may return an error value will have a compatible error type or types in the return type of the function.

import ballerina/io;

const INVALID_USERNAME = "InvalidUsername";

// A username validation function that returns an `error` value
// for invalid usernames, or `()` if the username is valid.
function validateUsername(string username) returns error? {
if username.length() < 6 {
return error(INVALID_USERNAME, reason = "invalid length");
}

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

// `()` is returned if this point is reached, indicating a valid username.
}

public function main() {
error? res = validateUsername("ma");
io:println(res is error); // true
io:println(res); // error("InvalidUsername",reason="invalid length")

io:println(validateUsername("m a r y a m")); // error("InvalidUsername",reason="contains spaces")

io:println(validateUsername("maryam") is error); // false
}

The error needs to be handled wherever the function is called. For example, the following will result in compilation errors since the error scenario isn’t handled.

public function main() {
_ = validateUsername("ma"); // error: can't use `_` with potential error values

validateUsername("maryam"); // error: can't ignore the return value
}

Abrupt termination via panic

An error value can also result in abrupt termination via panic, if used in a panic statement (or with checkpanic explained later on).

If not explicitly handled with trap (explained later on) along the call-stack, a panic results in program termination.

import ballerina/io;

const INVALID_ACC_ID = "InvalidAccountId";
const INVALID_ACC_ID_CODE = "E1011";

function updateAccount(int accountId, decimal amount) {
if accountId < 1000 {
panic error(INVALID_ACC_ID, code = INVALID_ACC_ID_CODE);
}

// Account update logic.
io:println("Updated Account for ID ", accountId, " , Amount: ", amount);
}

public function main() {
updateAccount(2500, 1500.00);
updateAccount(25, 1000.00);
}

While the first update will be successful, the second update attempt will panic. Since the panic is not trapped, the program will terminate.

$ bal run sample.bal
Updated Account for ID 2500 , Amount: 1500.00
error: InvalidAccountId {"code":"E1011"}
at sample:updateAccount(sample.bal:8)
sample:main(sample.bal:17)

The stack trace of the error associated with the panic is printed.

Panicking is only expected to be done in exceptional scenarios — i.e., non-recoverable scenarios (e.g., invalid state errors, index out of range errors, etc.). Business logic related errors would usually be returned.

Handling errors

If a function could potentially return an error value, the result of the function call could be used in an is check to identify if an error occurred, and act accordingly. Note how in the following example the register function only adds an account if the username validation does not return an error (i.e., the username is valid).

import ballerina/log;

const INVALID_USERNAME = "InvalidUsername";

// A username validation function that returns an `error` value
// for invalid usernames, or `()` if the username is valid.
function validateUsername(string username) returns error? {
if username.length() < 6 {
return error(INVALID_USERNAME, reason = "invalid length");
}

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

// `()` is returned if this point is reached, indicating a valid username.
}

function register(string username) {
error? validationRes = validateUsername(username);

if validationRes is error {
// `validationRes` is of type `error` within this block
log:printError("Error registering user, invalid username", validationRes);
} else {
// `validationRes` is of type nil within this block.
// Add registration logic.
}
}

By using the is check against error in the if condition we narrow the type of validationRes to error within the if block, which allows us to pass it directly as the second argument to log:printError which expects an error value.

check

In certain scenarios, if an expression (or action) evaluates to an error value, you may want to return the error value as is without further processing.

function register(string username) returns error? {
error? validationRes = validateUsername(username);

if validationRes is error {
// `validationRes` is of type `error` within this block
return validationRes;
} else {
// `validationRes` is of type nil within this block.
// Add registration logic.
}
}

Instead of using an is check and returning as shown in the example, Ballerina provides a special kind of expression — the check expression — as shorthand for the same. The register function can be simplified using check as follows.

function register(string username) returns error? {
check validateUsername(username);

// We get here only if the `validateUsername` function returned nil.
// Add registration logic.
}

Let’s look at another example where the type of the expression with which check is used is a union of error and a non-nil type.

import ballerina/io;

function get(map<decimal> configMap, string key) returns decimal|error {
if configMap.hasKey(key) {
return configMap.get(key);
}

return error("key not found");
}

function compare(map<decimal> configMap, string key, decimal threshold) returns boolean|error {
decimal|error config = get(configMap, key);

if config is error {
// The type of `config` is `error` here.
return config;
}

// Since the error case is handled in the if block and the error is always returned,
// the type of `config` is safely narrowed to the non-error type (`decimal`) here.
return config >= threshold;
}

public function main() {
map<decimal> configMap = {
factor: 5,
'default: 2
};

io:println(compare(configMap, "factor", 6)); // false

io:println(compare(configMap, "ratio", 0.6)); // error("key not found")
}

The compare function can be simplified as follows using a check expression.

function compare(map<decimal> configMap, string key, decimal threshold) 
returns boolean|error {
decimal config = check get(configMap, key);
return config >= threshold;
}

This can be simplified even further by making it an expression-bodied function.

function compare(map<decimal> configMap, string key, decimal threshold) 
returns boolean|error =>
check get(configMap, key) >= threshold;

checkpanic

Similarly, you may want to terminate the program, if a particular expression (or action) evaluates to an error value. One way to do this would be to evaluate the expression to get the result, do the is check, and panic if the result is an error.

For example, with the get function that returns decimal|error,

function compare(map<decimal> configMap, string key, decimal threshold) 
returns boolean|error {
decimal|error config = get(configMap, key);

if config is error {
panic config;
}

// Since the error case is handled in the if block and always results
// in a panic, the type of `config` is safely narrowed to the
// non-error type (`decimal`) here.
return config >= threshold;
}

Alternatively, you could use the checkpanic expression, which works similar to check, except that instead of returning the error if the expression (or action) evaluates to error, it panics.

import ballerina/io;

function get(map<decimal> configMap, string key) returns decimal|error {
if configMap.hasKey(key) {
return configMap.get(key);
}

return error("key not found");
}

function compare(map<decimal> configMap, string key, decimal threshold)
returns boolean =>
checkpanic get(configMap, key) >= threshold;

public function main() {
map<decimal> configMap = {
factor: 5,
'default: 2
};

io:println(compare(configMap, "factor", 6));

io:println(compare(configMap, "ratio", 0.6));

io:println(compare(configMap, "default", 4));
}

Note how the return type of compare can now be just boolean rather than boolean|error since the function does not return an error, but panics on error instead.

$ bal run sample.bal
false
error: key not found
at sample:get(sample.bal:8)
sample:compare(sample.bal:12)
sample:main(sample.bal:22)

Note how the program terminates due to the panic. The last comparison does not run.

on fail

The semantic of check is that it fails when the expression it is used with evaluates to an error. It is up to the enclosing block to decide how the failure needs to be handled.

With the examples we saw previously, the enclosing block would be the function, which implies that the error value simply needs to be returned from the function.

Alternatively, the check expression could be enclosed within a block to which an on fail clause is attached. In such a scenario, control is transferred to the on fail clause with the associated error value. Similar to the previous examples, if the expression with which check was used evaluates to an error value, the subsequent logic within the original block isn’t executed.

import ballerina/io;
import ballerina/log;

function get(map<decimal> configMap, string key) returns decimal|error {
if configMap.hasKey(key) {
return configMap.get(key);
}

return error("key not found");
}

function compare(map<decimal> configMap, string key, decimal threshold) {
do {
if key.trim().length() == 0 {
check error("cannot use a blank string as the key");
}

decimal config = check get(configMap, key);
io:println("Comparison: ", config >= threshold);
} on fail error e {
log:printError("config retrieval failed", e);
}
}

public function main() {
map<decimal> configMap = {
factor: 5,
'default: 2
};

compare(configMap, "factor", 4);
compare(configMap, "", 1);
compare(configMap, "ratio", 4);
}

The compare function now has a do statement with an on fail clause attached to it. Within the do block, check is used with two expressions that can evaluate to an error value — if the key specified is a blank string or if the get function returns an error value due to the key not being available. If none of these happen, in the last line of the do block the comparison is done and printed using the io:println function. Unlike the previous functions, now, if the expressions with which check is used evaluate to error values, control is transferred to the on fail clause rather than the error values being returned from the function. Therefore, the return type does not include an error type.

$ bal run sample.bal 
Comparison: true
time = 2023-04-24T12:27:31.989+05:30 level = ERROR module = "" message = "config retrieval failed" error = "cannot use a blank string as the key"
time = 2023-04-24T12:27:32.031+05:30 level = ERROR module = "" message = "config retrieval failed" error = "key not found"

The on fail clause can have any logic, which means it is also possible to handle the error as required and then return the error value from the function itself.

function compare(map<decimal> configMap, string key, decimal threshold) 
returns boolean|error {
do {
if key.trim().length() == 0 {
check error("cannot use a blank string as the key");
}

decimal config = check get(configMap, key);
return config >= threshold;
} on fail error e {
log:printError("config retrieval failed", e);
return e;
}
}

on fail provides a way to handle multiple errors in a single place.

However, note that on fail is not similar to say a try-catch block in Java. Control is transferred to the on fail clause only for usages of check and fail (see the next section). Returned errors or panics will not cause transfer of control to the on fail clause.

import ballerina/io;
import ballerina/log;

function get(map<decimal> configMap, string key) returns decimal|error {
if configMap.hasKey(key) {
return configMap.get(key);
}

return error("key not found");
}

function compare(map<decimal> configMap, string key, string threshold)
returns boolean|error? {
do {
if configMap.length() == 0 {
return error("config map is empty");
}

if key.trim().length() == 0 {
panic error("cannot use a blank string as the key");
}

return check get(configMap, key) >= check decimal:fromString(threshold);
} on fail error e {
log:printError("config retrieval failed", e);
return e;
}
}

public function main() {
map<decimal> configMap = {
factor: 5,
'default: 2
};

// 1. Empty config map - error is returned, therefore, no transfer of control
// to the `on fail` clause.
boolean|error? v1 = compare({}, "factor", "4");
io:println(v1);

// 2. Unavailable key - used with `check`, therefore, control is transferred
// to the `on fail` clause, causing the error to be logged.
boolean|error? v2 = compare(configMap, "ratio", "4");
io:println(v2);

// 3. Blank string as key - panics, therefore, no transfer of control
// to the `on fail` clause. Also results in program termination.
boolean|error? v3 = compare(configMap, "", "1");
}
$ bal run sample.bal 
error("config map is empty")
time = 2023-04-24T12:28:28.809+05:30 level = ERROR module = "" message = "config retrieval failed" error = "key not found"
error("key not found")
error: cannot use a blank string as the key
at sample:compare(sample.bal:20)
sample:main(sample.bal:48)

Note how the log message is logged only for the expression (that evaluated to an error value) that was used with check — key not found.

The typed binding pattern in an on fail clause is optional, meaning you could skip it if you don’t want to access the error value within the on fail clause.

function compare(map<decimal> configMap, string key, decimal threshold) 
returns boolean? {
do {
if key.trim().length() == 0 {
check error("cannot use a blank string as the key");
}

decimal config = check get(configMap, key);
return config >= threshold;
} on fail {
log:printError("config retrieval failed");
return ();
}
}

fail

Ballerina also supports the fail statement, which works similar to a check statement or expression, except that it can be used only with an expression that will always evaluate to an error value (i.e., the static type of the expression should be a subtype of error).

So instead of using check with an error constructor (or any expression that is of a static type that is a subtype of error), we can use fail.

function compare(map<decimal> configMap, string key, decimal threshold) 
returns boolean|error {
do {
if key.trim().length() == 0 {
fail error("cannot use a blank string as the key");
}

decimal config = check get(configMap, key);
return config >= threshold;
} on fail error e {
log:printError("config retrieval failed", e);
return e;
}
}

Previously (first example in the section on on fail), we used check with an error constructor if the key was a blank string. Now we have used a fail statement instead.

Handling panics

Given that a particular expression (or action) could panic, you may also want to avoid program termination by handling the associated error value explicitly. This could be done using the trap expression.

Let’s use the get function from the lang.map lang library as an example. When used with a map of type map<int|string>, the return type of the function is int|string. This function panics if the key specified is not available in the map.

import ballerina/io;

public function main() {
map<int|string> m = {
factor: 5,
'default: 2,
name: "def"
};

int|string ratio = m.get("ratio"); // panics
io:println(ratio);

io:println(m.get("factor"));
}

Note how the first get call panics, causing abrupt termination of the program.

$ bal run sample.bal 
error: {ballerina/lang.map}KeyNotFound {"message":"cannot find key 'ratio'"}
at ballerina.lang.map.0:get(map.bal:84)
sample:main(sample.bal:10)

Now let’s use trap with this expression that may panic. When used in a trap expression, the resultant type of trap m.get(k) would be the union of the type of the expression (m.get(k) here), which is int|string, and error.

import ballerina/io;

public function main() {
map<int|string> m = {
factor: 5,
'default: 2,
name: "def"
};

// The type of `ratio` should now include `error`.
int|string|error ratio = trap m.get("ratio");
io:println(ratio);

io:println(m.get("factor"));
}
$ bal run sample.bal
error("{ballerina/lang.map}KeyNotFound",message="cannot find key 'ratio'")
5

Note how the program does not terminate abruptly now, even though the first get call panics.

Although we haven’t used them yet, it is possible to define subtypes of errors, as we will see in a different article. With these custom subtypes, functions can have more refined return types rather than the generic error type. However, with the trap expression, the static type will always be the union with the builtin error type since it is not possible to identify the exact errors an expression (or action) may panic with by looking at the type of the expression.

This was a simple introduction to the different concepts and constructs associated with the error type and error values in Ballerina. In a few other articles we will look at how we can define and work with custom errors, destructuring and matching error values, etc.

Also see — official examples on error handling in Ballerina

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

Also published on Stack Overflow.

--

--