Exceptions, die, and typed error objects#

This chapter covers how to raise, catch, and classify runtime failures: die and eval, the native try/catch, special-variable hygiene ($@, $!, $?), $SIG{__DIE__}, and the design of exception classes.

Readers who know die and eval { } will find here the modern replacements for string-dispatching on exception messages and the hazards of $SIG{__DIE__} that the stock eval pattern silently hides.

die and eval#

die throws an exception; the nearest enclosing eval { } catches it and leaves the message in $@:

eval {
    risky();
};
if (my $err = $@) {
    warn "caught: $err";
}

Without a trailing \n, die’s argument gets at FILE line N. appended from the caller’s frame. With \n, no location is appended — use for messages that will be shown to users, omit for messages you want to trace.

Never use the string form eval "..." for exception handling. It compiles its argument and has every hazard of user-supplied code.

The native try/catch#

On Perl 5.34 and later, a dedicated try/catch is available behind a feature flag. Since 5.40 it is non-experimental:

use v5.40;
use feature 'try';

try {
    risky();
}
catch ($e) {
    warn "caught: $e";
}

finally remains experimental as of 5.42. Expect:

use feature 'try';
no warnings 'experimental::try';

on perls between 5.34 and 5.40.

For code that must run on 5.34 or 5.36 and also on newer perls, Feature::Compat::Try picks the core feature where available and falls back to Syntax::Keyword::Try:

use Feature::Compat::Try;

try { risky(); }
catch ($e) { warn "caught: $e" }

Why native try over Try::Tiny#

Try::Tiny still works and remains common. Prefer the native form for new code: it avoids the sub-call frame that Try::Tiny introduces (which shows up in stack traces), has zero startup cost, and composes with the isa operator cleanly.

These are retired — do not adopt:

  • TryCatch — abandoned.

  • Error.pm — superseded.

  • Exception::Class::TryCatch — superseded by Throwable.

$@ clobbering — the classic eval hazard#

$@ is set by eval; it is also observable globally. Any code that runs between eval’s return and your check on $@ — a DESTROY method, a tied-variable handler, an unrelated nested eval — can overwrite it. Always copy first:

eval { risky(); };
my $err = $@;
return unless $err;

Native try/catch and Syntax::Keyword::Try avoid this pitfall by giving you the error as the catch parameter; raw eval does not. Prefer the former for any code that takes exceptions seriously.

Special variables adjacent to exceptions#

Variable

Set by

Read immediately because…

$@

eval { } / die

any intervening call can overwrite.

$!

Last failed system call

any following syscall overwrites.

$?

system / backticks child

the next child overwrites.

$^E

Extended OS error

on non-Unix; on Linux usually mirrors $!.

$? decoding after system / backticks:

if ($? == -1)         { warn "child failed to start: $!" }
elsif ($? & 127)      { warn "child killed by signal " . ($? & 127) }
elsif ($? & 128)      { warn "core dumped" }        # combined with above
else                  { my $exit = $? >> 8; ... }

Classifying exceptions: dispatch on type#

For non-trivial programs, distinguishing “user not found” from “database is down” from “programming error” by parsing error strings is fragile. Throw typed objects and match on them:

use v5.40;
use feature 'try';
use Scalar::Util qw(blessed);

try {
    do_business();
}
catch ($e) {
    if    ($e isa MyApp::Err::NotFound)    { return http_404($e) }
    elsif ($e isa MyApp::Err::AuthFailure) { return http_403($e) }
    elsif ($e isa MyApp::Err)              { return http_500($e) }
    elsif (blessed($e) && $e->isa('DBIx::Class::Exception')) {
        return http_500($e);
    }
    else {
        MyApp::Err::Unknown->throw(cause => "$e");
    }
}

The isa operator (stable on 5.36+) does the right thing for overloaded objects and is zero-cost when the left operand is not a reference. Pair it with a typed wrapper at the outermost boundary so everything downstream is a MyApp::Err.

Designing exception classes#

The 2026 default: Throwable role from CPAN, optionally via Throwable::SugarFactory for declarative hierarchies.

Minimum-viable class:

package MyApp::Err::NotFound;
use Moo;
with 'Throwable';

has resource => ( is => 'ro', required => 1 );
has id       => ( is => 'ro', required => 1 );

sub message {
    my $self = shift;
    sprintf 'not found: %s/%s', $self->resource, $self->id;
}

1;

Usage:

MyApp::Err::NotFound->throw(resource => 'user', id => $uid);

throw is provided by the role; it dies with $self (the blessed object). ->new without throw builds without raising.

Declarative form via Throwable::SugarFactory:

package MyApp::Err;
use Throwable::SugarFactory;

exception 'GenericError'      => 'something bad happened';
exception 'NotFound'           => 'resource not found'
                                  => (has => [ id => (is => 'ro') ]);
exception 'AuthFailure'        => 'auth failed'
                                  => (has => [ reason => (is => 'ro') ]);
exception 'AuthFailureExpired' => 'session expired'
                                  => (extends => 'AuthFailure');
1;

This generates: the class, a not_found(id => ...) constructor- and-throw helper, a is_not_found($e) predicate, and ->to_hash on every instance for JSON serialisation.

What a class must have#

  1. A class name — so $e isa MyApp::Err::X dispatches.

  2. Structured fieldsid, resource, code.

  3. A human message — without it, warn $e prints MyApp::Err=HASH(0x...).

  4. Serialisation->to_hash for logging / aggregators / JSON APIs.

  5. Origin — file + line captured at throw. Throwable captures these automatically.

  6. A stack trace if the error is worth investigating later: with 'StackTrace::Auto' adds ->stack_trace.

When not to use a class#

Plain-string die remains correct for short scripts, one-offs, and any case where no caller will dispatch on the error:

die "permission denied: $path\n";

The trailing \n suppresses file-and-line appending; the message is user-facing.

Hash-ref diedie { type => 'not_found', id => $uid } — is a transitional form. The moment a caller writes if (ref $e eq 'HASH' && $e->{type} eq 'not_found') three times, refactor to a class.

Retired exception frameworks — do not adopt:

  • Error.pm — use Throwable (role-based) or Exception::Class.

  • Class::Throwable — functional; Throwable is the modern choice.

  • Exception::Class::TryCatch — superseded by native try.

$SIG{__DIE__} — handle with care#

$SIG{__DIE__} fires on every die, including die inside eval. A naive install turns every caught exception into noise:

# WRONG: fires on every eval that catches an error
$SIG{__DIE__} = sub { Carp::confess(@_) };

The $^S special variable tells you whether you are inside an eval: true means “yes, someone is going to catch this”; false means “this is reaching the top”. Guard on it:

$SIG{__DIE__} = sub {
    return if $^S;               # inside eval — not for us
    my ($err) = @_;
    eval { ship_to_sentry($err) };    # never let the handler itself die
    warn "reporter failed: $@" if $@;
    die $err;                    # re-raise
};

For Sentry / aggregator integration, wrap the reporter call in its own eval — a failing error reporter must not obscure the original exception.

Before installing on top of an existing handler, chain:

my $previous = $SIG{__DIE__};
$SIG{__DIE__} = sub {
    return if $^S;
    my ($err) = @_;
    eval { ship_to_sentry($err) };
    warn "reporter failed: $@" if $@;
    goto &$previous if $previous;
    die $err;
};

$SIG{__WARN__} — turn warnings fatal, or route them#

Symmetric to $SIG{__DIE__}, fires on every warn:

$SIG{__WARN__} = sub { die $_[0] };      # promote warnings to exceptions

Useful during development as a tripwire; rarely wanted in production. More common production use: route warnings through a logger.

Find out more#