Skip to content

Error Handling

Typist provides several complementary approaches to error handling: ADT-based Result/Option types for value-level error tracking, algebraic effects for scoped exception handling, and combinations of both. This page covers each pattern and when to use it.


The Result Type

A Result[T] encodes success or failure as a value. The error is part of the return type, so the caller is forced to handle it.

use v5.40;
use Typist;

BEGIN {
    datatype 'Result[T]' => (
        Ok  => '(T)',
        Err => '(Str)',
    );
}

sub parse_int :sig((Str) -> Result[Int]) ($s) {
    if ($s =~ /\A-?\d+\z/) {
        Ok(int($s));
    } else {
        Err("Not a number: $s");
    }
}

Consuming a Result

Use match to handle both arms:

my $result = parse_int("42");

my $value = match $result,
    Ok  => sub ($n) { $n },
    Err => sub ($msg) { die "Parse failed: $msg\n" };

say $value;    # 42

Chaining Results

For sequential operations that each return a Result, extract and re-wrap:

sub parse_and_double :sig((Str) -> Result[Int]) ($s) {
    my $parsed = parse_int($s);
    match $parsed,
        Ok  => sub ($n) { Ok($n * 2) },
        Err => sub ($msg) { Err($msg) };
}

Result with Typed Errors

For richer error information, use a struct instead of a plain Str:

BEGIN {
    struct ParseError => (
        input   => 'Str',
        message => 'Str',
        optional(position => 'Int'),
    );

    datatype 'ParseResult[T]' => (
        Parsed    => '(T)',
        ParseFail => '(ParseError)',
    );
}

sub parse_csv_field :sig((Str, Int) -> ParseResult[Str]) ($line, $col) {
    my @fields = split /,/, $line;
    if ($col < scalar @fields) {
        Parsed($fields[$col]);
    } else {
        ParseFail(ParseError(
            input    => $line,
            message  => "Column $col out of range",
            position => $col,
        ));
    }
}

The Option Type

Option[T] models the presence or absence of a value, replacing ad-hoc undef checks with a structured type.

BEGIN {
    datatype 'Option[T]' => (
        Some => '(T)',
        None => '()',
    );
}

sub find_user :sig((Int) -> Option[Str]) ($id) {
    my %users = (1 => "Alice", 2 => "Bob");
    exists $users{$id} ? Some($users{$id}) : None();
}

Consuming an Option

my $user = find_user(1);

my $name = match $user,
    Some => sub ($n) { $n },
    None => sub { "anonymous" };

say $name;    # "Alice"

Option vs Maybe

Typist also has a built-in Maybe[T] type, which is sugar for T | Undef. The difference:

Type Representation Pattern matching Narrowing
Maybe[T] T | Undef (union) defined($x) check Control-flow narrowing
Option[T] ADT with Some/None match with arms Exhaustive match

Use Maybe[T] for simple nullable values where a defined check suffices. Use Option[T] when you want explicit match exhaustiveness and richer composition.

# Maybe style -- simpler, uses narrowing
sub greet_maybe :sig((Maybe[Str]) -> Str) ($name) {
    if (defined($name)) {
        "Hello, $name!";         # $name narrowed to Str
    } else {
        "Hello, stranger!";
    }
}

# Option style -- explicit match, exhaustive
sub greet_option :sig((Option[Str]) -> Str) ($opt) {
    match $opt,
        Some => sub ($name) { "Hello, $name!" },
        None => sub { "Hello, stranger!" };
}

Effect-Based Error Handling

Algebraic effects provide a different approach: errors are declared as effects and handled at the call site, not encoded in return types.

Using the Built-in Exn Effect

The Exn effect bridges Perl's die to the handler system:

sub load_config :sig((Str) -> Str ![Exn]) ($path) {
    open my $fh, '<', $path or die "Cannot open $path: $!\n";
    my $content = do { local $/; <$fh> };
    close $fh;
    $content;
}

my $config = handle {
    load_config("/etc/myapp.conf");
} Exn => +{
    throw => sub ($err) {
        warn "Config error: $err";
        '{}';    # return default config
    },
};

Custom Error Effects

For domain-specific error handling, define your own effect:

BEGIN {
    effect AppError => +{
        not_found  => '(Str) -> Void',
        forbidden  => '(Str) -> Void',
        validation => '(Str) -> Void',
    };
}

sub get_user :sig((Int) -> Str ![AppError]) ($id) {
    if ($id <= 0) {
        AppError::validation("Invalid user ID: $id");
    }
    my %users = (1 => "Alice", 2 => "Bob");
    unless (exists $users{$id}) {
        AppError::not_found("User $id not found");
    }
    $users{$id};
}

Handling at the Boundary

The caller decides how to handle each error case:

# In a web handler: map to HTTP responses
my $response = handle {
    my $user = get_user($request_id);
    +{ status => 200, body => $user };
} AppError => +{
    not_found  => sub ($msg) { +{ status => 404, body => $msg } },
    forbidden  => sub ($msg) { +{ status => 403, body => $msg } },
    validation => sub ($msg) { +{ status => 400, body => $msg } },
};

# In a CLI: print and exit
handle {
    my $user = get_user($cli_id);
    say $user;
} AppError => +{
    not_found  => sub ($msg) { die "Error: $msg\n" },
    forbidden  => sub ($msg) { die "Access denied: $msg\n" },
    validation => sub ($msg) { die "Bad input: $msg\n" },
};

Combining Result with Effects

Result types and effects are complementary. A function can return a Result for expected failures while using effects for infrastructure concerns:

BEGIN {
    effect DB => +{
        query => '(Str) -> Str',
    };

    effect Logger => +{
        log => '(Str) -> Void',
    };
}

sub find_order :sig((Int) -> Result[Str] ![DB, Logger]) ($id) {
    Logger::log("Looking up order $id");
    my $data = DB::query("SELECT * FROM orders WHERE id = $id");
    if ($data eq '') {
        Err("Order $id not found");
    } else {
        Ok($data);
    }
}

The caller handles effects at the boundary while processing results in the business logic:

my $result = handle {
    handle {
        find_order(42);
    } DB => +{
        query => sub ($sql) { "order_data" },    # real DB call here
    };
} Logger => +{
    log => sub ($msg) { say STDERR $msg },
};

# $result is a Result[Str] -- process it
my $order = match $result,
    Ok  => sub ($data) { $data },
    Err => sub ($msg) { die "Failed: $msg\n" };

Guidelines: Result vs Effect

Concern Use Result Use Effect
Expected domain errors (not found, validation) Good fit Works, but heavier
Infrastructure concerns (I/O, logging, state) Awkward Good fit
Caller controls error recovery strategy Match at call site Handle at boundary
Error must be part of the type signature Yes (return type) Yes (effect row)
Composing with other errors Manual chaining Nested handle blocks

A pragmatic approach: use Result for domain-level expected outcomes and effects for cross-cutting infrastructure concerns.


Practical Patterns

Early Return on Error

sub process_order :sig((Str) -> Result[Str]) ($raw) {
    my $parsed = parse_int($raw);
    my $id = match $parsed,
        Ok  => sub ($n) { $n },
        Err => sub ($msg) { return Err("Bad order ID: $msg") };

    my $user = find_user($id);
    my $name = match $user,
        Some => sub ($n) { $n },
        None => sub { return Err("Unknown user: $id") };

    Ok("Order for $name");
}

Default Values with Option

sub user_display :sig((Int) -> Str) ($id) {
    my $user = find_user($id);
    match $user,
        Some => sub ($name) { $name },
        None => sub { "User #$id" };
}

Accumulating Errors

sub validate_order :sig((Str, Str, Str) -> Result[ArrayRef[Str]]) ($name, $email, $amount) {
    my @errors;
    push @errors, "Name is required"       unless length($name);
    push @errors, "Invalid email"          unless $email =~ /@/;
    push @errors, "Amount must be numeric" unless $amount =~ /\A\d+\z/;

    if (@errors) {
        Err(join('; ', @errors));
    } else {
        Ok([$name, $email, $amount]);
    }
}