perl.gg / hidden-gems

<!-- category: hidden-gems -->

Prototypes Are NOT Type-Checking

2026-04-24

Pop quiz. What does this prototype do?
sub add ($$) { return $_[0] + $_[1]; }
If you said "it checks that two scalars are passed," you're wrong. So is almost everyone else. Don't feel bad. This is one of the most widely misunderstood features in all of Perl.

Prototypes do not check types. They do not validate arguments at runtime. They change how Perl parses the call site at compile time. They are hints to the parser, not guards on your function.

And they are silently ignored on method calls.

Part 1: WHAT PEOPLE THINK PROTOTYPES DO

The name "prototype" is the problem. In C, C++, Java, and every statically typed language, a function prototype declares parameter types. The compiler uses it to catch type errors.

So when Perl programmers see this:

sub multiply ($$) { return $_[0] * $_[1]; }
They assume ($$) means "this function takes exactly two scalars, and Perl will complain if you pass anything else." Reasonable guess. Completely wrong.

What ($$) actually means: "when parsing a call to this function, treat the first two arguments as scalar expressions." It changes how Perl reads your code, not how it runs your code.

Part 2: WHAT PROTOTYPES ACTUALLY DO

Prototypes are compile-time parsing hints. They tell the Perl parser how to interpret the argument list at the call site.

The ($) prototype forces scalar context on the argument:

sub count_items ($) { say "Got: $_[0]"; } my @arr = (1, 2, 3, 4, 5); count_items(@arr); # prints "Got: 5" (array in scalar context = count)
Without the prototype:
sub count_items { say "Got: $_[0]"; } my @arr = (1, 2, 3, 4, 5); count_items(@arr); # prints "Got: 1" (array flattened, first element)
See the difference? The prototype changed what @arr means at the call site. With ($), Perl evaluates @arr in scalar context before the function even sees it. The function receives the number 5, not the list (1, 2, 3, 4, 5).

No type checking happened. Perl just parsed the call differently.

Part 3: THE (@) AND (\@) DISTINCTION

The (@) prototype is a no-op. It means "slurp everything as a list." That's what Perl does by default anyway.
sub show_all (@) { say for @_; }
The (\@) prototype, with the backslash, is where things get interesting. It means "take a reference to the array, don't flatten it":
sub show_ref (\@) { my ($aref) = @_; say "Array has " . scalar @$aref . " elements"; } my @nums = (10, 20, 30); show_ref(@nums); # prints "Array has 3 elements"
Without the (\@) prototype, you'd have to pass the reference explicitly:
show_ref(\@nums); # without prototype, you need the backslash
The prototype lets you call show_ref(@nums) and Perl automatically takes the reference for you at compile time. The function receives an array reference, not a flattened list.

This is how push works. It's a builtin with an implicit (\@) prototype. That's why push @array, $value works without you writing push \@array, $value.

Part 4: WHY PROTOTYPES ARE IGNORED ON METHOD CALLS

Here's the killer. Method calls completely ignore prototypes:
package Calculator; sub new { bless {}, shift } sub add ($$) { my ($self, $a, $b) = @_; return $a + $b; } package main; my $calc = Calculator->new; $calc->add(1, 2, 3, 4, 5); # no error, no warning
Five arguments. The prototype says two. Perl does not care.

Why? Because method calls are resolved at runtime through method dispatch. The prototype is a compile-time hint. At compile time, Perl doesn't know which add method will be called. It could be in the current class, a parent class, or loaded dynamically. Since Perl can't know which function the method resolves to, it can't apply its prototype.

So it doesn't try.

Part 5: WHY OO CODE NEVER USES THEM

This is why you almost never see prototypes in object-oriented Perl. They literally do nothing on method calls. Every method call is $obj->method(args) or Class->method(args), and prototypes are ignored for both.

Moose, Moo, and every other OO framework ignore prototypes. The entire modern Perl OO ecosystem pretends they don't exist. And they're right to do so.

# These prototypes accomplish nothing: package MyClass; sub new ($) { ... } # ignored on MyClass->new sub process ($$) { ... } # ignored on $obj->process(...) sub config (\%) { ... } # ignored on $obj->config(...)
Don't put prototypes on methods. They give readers false confidence that argument checking is happening. It isn't.

Part 6: WHEN PROTOTYPES ARE USEFUL

Prototypes have one legitimate purpose: making your subroutines behave like builtins.

Want a function that takes a block like map or grep?

sub apply_to_each (&@) { my $code = shift; my @results; for (@_) { push @results, $code->($_); } return @results; } my @doubled = apply_to_each { $_ * 2 } 1, 2, 3, 4; say join ", ", @doubled; # 2, 4, 6, 8
The (&@) prototype says "first argument is a code block, rest is a list." This lets you write apply_to_each { ... } @list without the sub keyword on the block. Just like map { ... } @list.

Without that prototype, you'd need:

my @doubled = apply_to_each(sub { $_ * 2 }, 1, 2, 3, 4);
The prototype makes the syntax cleaner. That's it. That's the whole benefit. No type checking. No argument validation. Just nicer syntax.

Part 7: THE (;) OPTIONAL MARKER

A semicolon in a prototype marks the boundary between required and optional arguments. Well, "required" is a strong word. It's more like "the parser expects these, and the rest are optional."
sub greet ($;$) { my ($name, $greeting) = @_; $greeting //= "Hello"; say "$greeting, $name!"; } greet("World"); # Hello, World! greet("World", "Howdy"); # Howdy, World!
The ($;$) says "one mandatory scalar, one optional scalar." But again, this is a parser hint. Perl won't die if you pass zero arguments or ten. You'll just get undef values and possibly a "Use of uninitialized value" warning.

Part 8: COMMON MISTAKES

Mistake 1: Using prototypes for argument validation.
# WRONG: this does not validate anything sub safe_divide ($$) { my ($a, $b) = @_; die "division by zero" if $b == 0; return $a / $b; }
The ($$) does not ensure two arguments are passed. If you call safe_divide(42), Perl happily passes 42 and undef.

For real argument validation, check manually:

sub safe_divide { die "Need exactly 2 arguments" unless @_ == 2; my ($a, $b) = @_; die "division by zero" if $b == 0; return $a / $b; }
Mistake 2: Prototype on a method.
# WRONG: prototype is ignored on method calls sub process ($$) { my ($self, $data) = @_; ... }
Mistake 3: Assuming (\@) validates the type.
sub sum_array (\@) { my ($aref) = @_; my $total = 0; $total += $_ for @$aref; return $total; } sum_array(@numbers); # works sum_array("hello"); # compile-time error, but not a "type check"
The (\@) forces a reference to be taken. It's a parsing transformation, not a type assertion.

Part 9: THE MODERN ALTERNATIVES

If you want actual argument validation, use signatures (Perl 5.20+):
use feature 'signatures'; sub add ($a, $b) { return $a + $b; } add(1, 2); # fine add(1); # dies: Too few arguments add(1, 2, 3); # dies: Too many arguments
Signatures do real argument count checking at runtime. They die with a clear error message if the count is wrong. They are not prototypes. They are a different feature entirely.

Or use a module like Params::Validate:

use Params::Validate qw(validate SCALAR); sub add { my %p = validate(@_, { a => { type => SCALAR }, b => { type => SCALAR }, }); return $p{a} + $p{b}; }
Real validation. Real error messages. Actually checks types.

Part 10: THE COMPLETE PICTURE

Here's a reference card. What each prototype character actually does at the call site:
CHARACTER PARSING EFFECT AT CALL SITE --------- ------------------------------------------ $ forces scalar context on the argument @ slurps remaining args as list (default) % slurps remaining args as key/value pairs & first arg is a code block (no sub keyword) * accepts a bareword, constant, scalar, or typeglob \$ takes a reference to a scalar \@ takes a reference to an array \% takes a reference to a hash \& takes a reference to a named subroutine ; separates mandatory from optional args _ like $ but defaults to $_ if omitted NONE of these check types at runtime. NONE of these are enforced on method calls. ALL of them are compile-time parsing hints.
+------------------+ | PROTOTYPES | | are NOT about | | what goes IN | | | | they're about | | how Perl READS | | the call site | +------------------+ | v .--. |o_o | "Parse hints, not guardrails." |:_/ | // \ \ (| | ) /'\_ _/`\ \___)=(___/
The takeaway: don't use prototypes for argument validation. They don't do that. Use them when you want your function to parse like a builtin. Use signatures or validation modules when you want actual checking. And never, ever put them on methods.

perl.gg