Using Type::Params Effectively
One of the modules bundled with Type::Tiny is Type::Params, a module that allows you to validate subroutine signatures using type constraints. It's one of the more popular parts of the suite.
This article provides a few hints for using it effectively.
When not to use Type::Params
There are a few places I occasionally see Type::Params being used where it's really not the best fit.
You just want to know if a variable meets a type constraint
Let's say you have a variable $x
and you want to know if it's an integer. I have seen people do things like this:
use v5.16; use Types::Standard qw( Int ); use Type::Params qw( validate ); if ( eval { validate [ $x ], Int } ) { say "x is an integer!"; }
(The eval
is used because validate
will throw an exception, when what we want is a boolean yes/no.)
This works, yes, but it can be made so much simpler!
The Int()
function returns an Int type constraint object which we can call methods on. In particular, the check
method.
use v5.16; use Types::Standard qw( Int ); if ( Int->check( $x ) ) { say "x is an integer!"; }
Because type libraries also export an is_X()
function for each type constraint, we can even go one step simpler:
use v5.16; use Types::Standard qw( is_Int ); if ( is_Int $x ) { say "x is an integer!"; }
This is not just easier to read; it will also benchmark much, much faster.
You want to assert that a variable meets a type constraint
This is a similar situation, except that here we want an exception to be thrown.
I see people do things like this:
use v5.16; use Types::Standard qw( Int ); use Type::Params qw( compile ); state $check_int = compile( Int ); $check_int->( $x ); # assert $x is an integer
Again, we can call methods on the Int type constraint to achieve the same result.
use v5.16; use Types::Standard qw( Int ); Int->assert_valid( $x );
Or we can use the assert_X()
functions exported by type constraint libraries:
use v5.16; use Types::Standard qw( assert_Int ); assert_Int $x;
Once again, this is not only much clearer, but will perform better in benchmarks.
When to use Type::Params
Type::Params is intended for validating and unpacking an entire list in one logical step. In particular this is useful for validating the @_
array of subroutine arguments.
Consider we have a class something like this:
use v5.16; package Point { use Moo; use Types::Common qw( Num ); has x => ( is => 'rw', isa => Num, default => 0 ); has y => ( is => 'rw', isa => Num, default => 0 ); sub move_to { my ( $self, $new_x, $new_y, $reason ) = @_; $self->x( $new_x ); $self->y( $new_y ); say $reason; return $self; } }
This looks safe because the type constraints will ensure that x
and y
will always be numeric.
However, what if somebody does $point->move_to( 1, '', 'whatever' )
? Setting y
to the empty string will throw an exception, but x
has already been changed, so the object is left in an inconsistent state. It hasn't moved to where we wanted it to move, but it hasn't stayed where it was either!
So it's a good idea to validate all the parameters to move_to
up front. Here's how you could do it using Type::Params:
use v5.16; package Point { use Moo; use Types::Common -types, -sigs; has x => ( is => 'rw', isa => Num, default => 0 ); has y => ( is => 'rw', isa => Num, default => 0 ); sub move_to { state $check = signature( method => Object, positional => [ Num, Num, Str ], ); my ( $self, $new_x, $new_y, $reason ) = &$check; $self->x( $new_x ); $self->y( $new_y ); say $reason; return $self; } }
The signature
function compiles the function's signature into a coderef called $check
. This is stored in a state
variable so that it is compiled only once.
The method
option indicates that this sub will be called as a method, so will have an invocant (which must be an Object). The positional
option indicates that this sub expects two numbers followed by a string.
Calling &$check
with no parentheses takes advantage of the Perl feature where @_
is automatically passed forward. The $check
coderef will now validate @_
and return the validated values. If validation fails, it will throw an exception.
Adding defaults
Type::Params also supports defaults:
state $check = signature( method => Object, positional => [ Num, Num, Str, { default => 'cos I want to' }, ], );
Now if the function is called without a $reason
, a default reason can be supplied. Simple non-reference defaults can be provided as above. If you need to build a more complex default, you can use:
default => sub { ... }
Switching to named parameters
Let's assume we would prefer to call the method like this:
$point->move_to( x => 1, y => 2, reason => 'whatever' )
Type::Params handles named parameters easily.
use v5.16; package Point { use Moo; use Types::Common -types, -sigs; has x => ( is => 'rw', isa => Num, default => 0 ); has y => ( is => 'rw', isa => Num, default => 0 ); sub move_to { state $check = signature( method => Object, named => [ x => Num, y => Num, reason => Str, { default => 'cos I want to' }, ], ); my ( $self, $arg ) = &$check; $self->x( $arg->x ); $self->y( $arg->y ); say $arg->reason; return $self; } }
The $check
coderef will now return just the invocant $self
and an object $arg
which allows access to the named arguments.
However, perhaps we want to accept named parameters without rewriting the guts of move_to
to deal with $arg
. We still want our $new_x
, $new_y
, and $reason
variables. That is also possible!
use v5.16; package Point { use Moo; use Types::Common -types, -sigs; has x => ( is => 'rw', isa => Num, default => 0 ); has y => ( is => 'rw', isa => Num, default => 0 ); sub move_to { state $check = signature( method => Object, named_to_list => 1, named => [ x => Num, y => Num, reason => Str, { default => 'cos I want to' }, ], ); my ( $self, $new_x, $new_y, $reason ) = &$check; $self->x( $new_x ); $self->y( $new_y ); say $reason; return $self; } }
The named_to_list
option tells $check
that it needs to accept named parameters, but return them in a positional list.
Transitioning
Now we have rewritten move_to
to accept named parameters, but perhaps there are still places all over our codebase that call move_to
with positional parameters. How can we accept both?
Type::Params allows multiple signatures to be combined into one using the multiple
option.
state $check = signature( method => Object, multiple => [ { named_to_list => 1, named => [ x => Num, y => Num, reason => Str, { default => 'cos I want to' }, ], }, { positional => [ Num, Num, Str, { default => 'cos I want to' }, ], }, ], ); my ( $self, $new_x, $new_y, $reason ) = &$check;
Now $check
will try each signature in order and see which seems to make sense. move_to
will accept both named and positional arguments.
Extra flexibility
For some reason, we now also want to allow people to call:
my @coordinates = ( $x, $y ); $point->move_to( \@coordinates, $reason )
And still preserve the two existing ways to call the function. Type::Params can still handle this situation. Firstly, we add another option to the multiple
list:
state $check = signature( method => Object, multiple => [ { named_to_list => 1, named => [ x => Num, y => Num, reason => Str, { default => 'cos I want to' }, ], }, { positional => [ Num, Num, Str, { default => 'cos I want to' }, ], }, { positional => [ Tuple[ Num, Num ], Str, { default => 'cos I want to' }, ], goto_next => sub { ... }, }, ], ); my ( $self, $new_x, $new_y, $reason ) = &$check;
An issue with this new calling style is that instead of returning four arguments (the invocant, two numbers, and the reason), it returns three arguments (the invocant, an arrayref, and the reason). But we can use goto_next
to unpack the arrayref:
goto_next => sub { my ( $self, $pair, $reason ) = @_; return ( $self, $pair->[0], $pair->[1], $reason ); },
And now it all works!
Perl 5.20 subroutine signatures
Perl 5.20 introduced subroutine signatures as an experimental feature. As of Perl 5.36, the feature is no longer experimental.
At first glance, this doesn't seem to fit well with Type::Params. Consider:
use v5.16; package Point { use Moo; use Types::Common -types, -sigs; has x => ( is => 'rw', isa => Num, default => 0 ); has y => ( is => 'rw', isa => Num, default => 0 ); sub move_to ( $self, $new_x, $new_y, $reason ) { state $check = signature( method => Object, positional => [ Num, Num, Str ], ); ( $self, $new_x, $new_y, $reason ) = $check->( $self, $new_x, $new_y, $reason ); $self->x( $new_x ); $self->y( $new_y ); say $reason; return $self; } }
It's... not neat. And we've lost the flexibility to support multiple different calling styles.
However, Type::Params also provides a signature_for
function which inverts its usual declaration style, putting the signature check outside the sub, and allowing you to use Perl subroutine signatures for the sub itself.
Here's how we'd use it with named + positional calling style:
use v5.16; package Point { use Moo; use Types::Common -types, -sigs; has x => ( is => 'rw', isa => Num, default => 0 ); has y => ( is => 'rw', isa => Num, default => 0 ); signature_for move_to => ( method => Object, multiple => [ { named_to_list => 1, named => [ x => Num, y => Num, reason => Str, { default => 'cos I want to' }, ], }, { positional => [ Num, Num, Str, { default => 'cos I want to' }, ], }, ], ); sub move_to ( $self, $new_x, $new_y, $reason ) { $self->x( $new_x ); $self->y( $new_y ); say $reason; return $self; } }
The signature_for
keyword operates at run-time, wrapping around the move_to
sub declared below it. The signature check takes care of ensuring the parameters are always sent as a list, so the Perl subroutine signature never needs to deal with named arguments.
Currently goto_next
is not supported by signature_for
, so the third calling style we had before will not work as expected. However, support is planned.
Hopefully this article has given you an idea of what Type::Params is capable of, how you can get the most out of it, and when it's better to use something else.
Leave a comment