Ballerina's Explicit Error Contract: Why Failure Belongs in Your Type Signature
Ballerina treats errors as typed return values, not invisible exceptions. See how union types and the check expression force you to handle failures at compile time—and what that costs you.
22 Sept 2026, 12:42 UTC

In most integration stacks, a failed downstream call is an exception that appears only when the code is already running. You wrap the call in a try/catch, hope you remembered the right exception class, and move on. Ballerina rejects that model. Failure is a typed return value, and the compiler will not build a service that ignores it. The practical payoff: you discover missing error handling before deployment, not during a 2 a.m. page.
This post looks at how Ballerina's error type, union return signatures, and the check expression work together to make error paths visible, using an HTTP client scenario as the concrete example. It also covers the verbosity cost and what happens at the Java interoperability boundary.
Failure Is Part of the Signature, Not a Surprise
Ballerina has a built-in error type. It is not a special exception class; it is a value you can assign, inspect, and return. When a function can fail, its return type is a union that includes error. A function that parses a configuration string into an integer might be declared like this:
function parsePort(string raw) returns int|error {
if raw is "" {
return error("empty port value");
}
return int:fromString(raw);
}
The returns int|error clause tells every caller that two outcomes are possible. You cannot pretend the failure path does not exist, because the type system will not let you assign the result to a plain int without handling the error first.
The check Expression Keeps the Happy Path Linear
Ballerina does not force you to write try/catch blocks for every call. Instead, the check expression evaluates an expression that returns a union with error; if the value is an error, check immediately returns that error from the enclosing function or resource. The success path stays flat:
function loadConfig(string filePath) returns json|error {
io:ReadableByteChannel ch = check io:openReadableFile(filePath);
// ... process channel
return {};
}
Here, check propagates the file-open failure to loadConfig's caller without an explicit if branch. The trade-off is that check only stops at the nearest enclosing function or service resource. Somewhere at the top of the call chain, you still need a boundary that logs, transforms, or maps the final error into a response.
Worked Example: HTTP Client and the Compiler as Gatekeeper
Integration code rarely fails on the happy path; it fails when a remote endpoint times out, returns a 500, or sends malformed JSON. Ballerina's HTTP client reflects this by returning a union that includes error. Consider a resource that calls a downstream service:
service /api on new http:Listener(8080) {
resource function get status() returns json|error {
http:Client downstream = check new ("http://placeholder-service:9090");
json payload = check downstream->get("/health");
return payload;
}
}
If you remove check and try to return the raw client result, the compiler rejects the build. The union json|error cannot be returned as json. That is the point: transport failures and unexpected status codes are not optional to handle.
To verify this locally, create a minimal Ballerina project and run ballerina build <project-path> from your terminal in the project directory. You do not need elevated permissions for a local build. If the resource signature declares returns json but the body returns an unhandled union, the expected check is a compiler diagnostic pointing at the return statement. The risk is that the build stops entirely; you cannot produce a runnable artifact until the error union is addressed.
For a visual check, open the same project in the Ballerina VS Code extension. The sequence diagram view renders error paths as alternate flows, so you can see which calls can fail and where the error is caught.
Trade-offs: Verbosity and Interop
The explicit model has a cost. A function that calls three remote services might end up with a signature like json|http:ClientError|error, which is harder to read than a single return type. The guidance and community practice suggest grouping related failures into a single error detail record rather than enumerating every variant. You can attach structured detail to an error value, so callers can inspect error.message(), error.cause(), and custom fields without pattern-matching a long union.
At the Java boundary, checked exceptions do not map one-to-one onto Ballerina's error values. Calling Java from Ballerina requires converting thrown exceptions into Ballerina errors, which adds a translation layer. The benefit is that once the error crosses into Ballerina, it follows the same explicit contract as a native failure.
An Actionable Starting Point
Pick one integration function in your current codebase that wraps a remote call. Rewrite it in Ballerina with a union return type and check propagation, then run ballerina build and observe whether the compiler flags any unhandled paths you previously missed. If you are evaluating Ballerina for the first time, confirm the exact standard-library error names and record fields against the distribution version you plan to deploy, because those identifiers can shift between releases. The goal is not to eliminate runtime failure—it is to make every failure a decision you made at compile time.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.