Perl's Data::Checks and Tagged Types, of a Sort
22 Aug 2026
Introduction
Data::Checks and its associated ecosystem of attributes allows for convenient definition of runtime value constraint assertions. An example provided in its documentation offers a nice, succinct overview of the capabilities on offer:
use Data::Checks qw( Str );
sub greet ( $message :Checked(Str) ) {
say "Hello, $message";
}
greet( "world" ); # is fine
greet( undef ); # throws an exceptionThe interface for checks is also nice and simple, requiring just a
check() method which returns true for valid values passed to it. This makes
the attributes compatible with the existing
Types::Standard ecosystem.
This is great for asserting appropriate values for parameters at the borders of your application, as part of the interface contract. I think we can take it just a little further with two small additions. The first is a continuous assertion that a variable contains an appropriate value - assignment and mutation will reject inappropriate values. The second is a type-tagging system which could help increase the expressiveness and robustness of a parameter validation sytem. Maybe.
Before we proceed, I should make it clear that I have absolutely no idea what I am doing. I do not understand type theory at all. I have not examined any prior art. There is certainly nothing new here - this has all been done before in some form or another. This is naught but an experiment.
Tagged Types
Tagged types offer a way to attach a semantic type to a variable, alongside its
value's type. That is, the variable has some metadata attached which describes its
purpose or category. Take, for example, a maths function which takes a parameter 'angle',
expressed in radians.
Radians can be represented by a simple float, though so may many other things,
such as degrees. How can we assert the incoming value is indeed expressed in
radians? Simply tag it as a radians value!
A natural approach to tagging values in Perl is to use an object instance.
The value itself can be encapsulated in a ref of some sort (or a field
for neo-objects), while the class
name and hierarchy offers the semantic context required. An earlier iteration of this experiment
was based around objects, with a series of operator overloads for stringification,
numification, assignment, and copying.
This worked, after a fashion. Working with the result, however, it was very clearly an object instance (a blessed ref) with a set of overloads for various circumstances, not a plain scalar with value and tag checking going on in the background somehow.
Enter tying with tie. This is a Perl
feature which allows class methods to transparently perform operations of
one's choice when values are accessed or mutated.
I'm Wearing a Tie, but I Don't Feel Classy
The first step is to define an abstract class with the
constructor in the TIESCALAR() method, as required by tie. Concrete
classes will need to define the check and coerce methods. FETCH() and
STORE() are called as the scalar is read and written, respectively.
package _abstract_tag {
sub check { ... }
sub coerce { ... }
sub TIESCALAR {
bless \$_[1], $_[0];
}
sub FETCH ( $self ) {
$$self;
}
sub STORE ( $self, $val ) {
$val = $self->coerce( $val );
$self->check( $val ) or
Carp::croak sprintf
'Value %s is not of type %s',
$val // '<undefined>', ref $self;
$$self = $val;
}
}TIESCALAR() accesses members of the function arguments array @_ here, as it's the simplest
way to get aliases of the passed values. We can also see STORE() calling
check() before setting the value if the check passes.
The next moving part is a function for creating new tags and adding the ability to declare the tagged variables to the calling namespace. Let's break this one down a little:
sub new_type (
$tag,
:$check = sub { true },
:$coerce = sub { shift }
) {
my $caller_meta = meta::get_package( _caller() );
my $type_meta = meta::get_package( $tag );
base->import::into( $tag, '_abstract_tag' );We start by pulling in a tag name, plus some technically optional
implementations for check() and coerce(), via the nice new named signature
parameters feature.
The function then starts by using meta
to pull in a metapackage for the calling package via a helper function
named _caller(). This walks up the call stack until it finds a package name
that isn't the current package:
sub _caller {
my $i = 1;
while ( defined ( my $caller = caller( $i++ ) ) ) {
$caller ne __PACKAGE__ && return $caller;
}
croak "No external caller!";
}Our new_type() function then goes on to create a package, again via meta,
for the supplied $tag. Import::Into
is used to set the parent class to the _abstract_tag class defined above.
We could mess about with @ISA in the metapackage here but this feels like the
cleaner approach.
Moving on:
$type_meta->add_symbol( '&check' => sub {
$check->( $_[1] ) &&
ref $_[0]
? true
: tied $_[1] isa $_[0];
});
$type_meta->add_symbol( '&coerce' => sub( $self, $val ) {
$coerce->( $val );
});The concrete implementations of check() and coerce() are added to the tag's package here. These
wrap the functions passed into new_type(). The concrete check() has two
modes of operation. If called from within the class, the first parameter will
be a reference - we can skip the isa check in this case. Calls from external
checkers will use a package name rather than an instance, so the isa check
is performed. This allows STORE() to check values before they are assigned/tied,
while external checkers will also assert the tag is correct.
We again use members of @_ here to ensure we get aliases to the passed
values.
Hopefully the utility of coerce() should become clear later.
my $tie_type = $caller_meta->add_symbol(
"&$tag" => sub :lvalue ( $val ) {
tie $$val, $tag;
$$val;
}
);
$tie_type->set_prototype( '\$' );
true;
}The final step is to add a function to the caller named after the requested
tag. Instead of unrolling @_ here, this uses an alternative approach of
using a prototype to coerce parameters into being a reference, and therefore
an alias of the passed value. I did it this way because I felt like it. I sure
hope this hubris doesn't fly back in my face somehow.
The tag function is also set to be a :lvalue, so assignment can be included
with declaration of new variables.
The new_type() function now provides a base for declaring more complete tagged
types.
Declaring New Tagged Types
We can now start writing (hopefully) smaller functions to create tagged types. Let's start with a basic numeric value:
sub new_num_type (
$tag,
:$check = sub { true },
:$coerce = sub { shift }
) {
new_type(
$tag,
check => sub( $val ) {
Num->check( $val ) &&
$check->( $val )
},
coerce => $coerce
)
}A pattern should already be revealing itself. This function will create new
tagged typed which assert their numeric-ness via
Data::Checks::Num. Any additional
constraint checkers passed to new_num_type() are also executed.
We already have enough to solve the radians problem from the introduction. An API of maths functions could create a new tag, and export it to allow its consumers to declare radians. This should look something like:
package Circly::Maths;
new_num_type( 'radians' );
sub do_something_with_radians( $angle :Checked(radians) ) { ... }
our @EXPORT_OK = qw/ radians do_something_with_radians /;Then in the API consumer:
use Circly::Maths qw/ radians do_something_with_radians /;
radians my $angle = 4.2;
do_something_with_radians( $angle ); # lives 🤞
radians my $new_angle = 'zero'; # not a Num - dies
do_something_with_radians( 0.42 ); # not tagged - also diesThe value is also checked on any assignment, so shouldn't find itself accidentally invalidated:
$angle = 2; # ok
$angle = 'foo'; # dies
$angle = {}; # also dies
$angle += 1; # $angle = 3Let's declare an integer checker:
sub new_int_type (
$tag,
:$check = sub { true },
:$coerce = sub { shift }
) {
new_num_type(
$tag,
check => sub( $val ) {
$val !~ /\D/ &&
$check->( $val )
},
coerce => $coerce
)
}We can see this builds upon the Num checker by adding a regex check on the
value to ensure it contains no characters matching \D - non digit character.
The coerce function could be declared so it does int() on assigned values,
though this would effectively make any assignment valid - non numbers would
coerce to 0, or a reference address. Coercions need a light touch.
# would make checks ineffective
coerce => sub( $val ) { $coerce->( int( $val ) ) }Let's try exploiting coercions a bit more effectively:
sub new_clamped_int_type (
$tag, $min, $max,
:$check = sub { true },
:$coerce = sub { shift }
) {
new_int_type(
$tag,
check => $check,
coerce => sub( $val ) {
$val = $min if $val < $min;
$val = $max if $val > $max;
$coerce->( $val );
}
)
}Here we have an Int checker which also coerces values to be within a given
range, specified by $min and $max. Values are silently clamped to be
within the valid range.
This example was inspired by MIDI. Most parameter values in MIDI are 7-bit - an integer between 0 and 127. Imagine pulling a value from a non-MIDI source, such as a gamepad, to control a MIDI device. There is a lot of opportunity to make mistakes when converting values, and cause an int overflow.
new_clamped_int_type( 'midicc', 0, 127 );
midicc my $cc = 128; # 127So far, so good...
$cc = 'foo'; # dies
$cc = {}; # 127...uh-oh - remember, references will numify to their address. Let's stringify them first, I guess:
$val = $min if "$val" < $min;
$val = $max if "$val" > $max;OK, we're good now, right?
$cc = {}; # dies
$cc = 126.9; # dies
$cc = 127.1; # 127Not quite. Non-int values are rejected, unless they are out of bounds.
Functionally this might work just fine, but the inconsistency could lead to
confusion and errors down the line. The example assignments here are also
far from exhaustive - further bugs are likely lurking in this coercion. It
appears that coerce() compromises check() and making it not do that is
challenging.
While range-clamping is perhaps appropriate for the MIDI value, it could cause unseen issues for other value checks. Imagine for our radians type, we coerced the value to be mod(2 * Pi) - we only care about absolute angles. Someone might later come along and decide to use this type to describe the extent of a rotation. A rotation of at least one circle cannot be represented in our radians variable with this coercion.
While asserting a value is within certain bounds is a perfectly valid thing to do, deciding what to do with out of bounds values is application dependent. In an ideal situation, a failing bounds check would throw a different exception to an invalid value check, so when caught we could respond to each situation differently.
Another issue is that when creating the clamped int type, $min and $max are
not checked. In a more complete system, coerce functions could perhaps receive
an instance of their tag class, perform checks on the proposed new value, and
only proceed to bounds clamping if a bounds exception is thrown. This would
also allow for min and max values to be checked.
I get the feeling that coercion as implemented here is just a misfeature.
Data::Checks and :Checked()
Our tagged types should integrate with the :Checked() attribute nicely.
The classes behind the scenes are required to implement check(), so ...
Let's try making a checked object field:
use Object::Pad;
use Object::Pad::FieldAttr::Checked;
class Circly::Maths {
use Tie::TaggedTypes; # the system described in this post
new_num_type( 'radians' );
field $circlish :param :reader :Checked(radians);
}
# ideally the Circly::Maths package would export radians(),
# but we'll use the full sub name for now.
Circly::Maths::radians my $tauish = 6.283185;
my $maths = Circly::Maths->new( circlish => $tauish );OK, let's go...
Bareword "radians" not allowed while "strict subs" in use at ...Oh, :Checked() doesn't know what radians are.
Not to worry, it looks like we need to place the tag import in a BEGIN
block so the import happens at compile time, making the symbol &radians
available for field declaration.
BEGIN {
use Tie::TaggedTypes;
new_num_type( 'radians' );
}Alright, this time...
Not enough arguments for Circly::Maths::radians at ...Ah, of course. This isn't the string 'radians' referring to the package, this
is the variable declaration function. A quick fix is
to ensure :Checked() uses the package name by stringifying it:
field $circlish :param :reader :Checked('radians');(An alternative approach to the above would be updating the constructed radians()
function to use aliased values and call exists on the parameter. If it exists,
proceed to tying, else return the tag/package name.)
...and (drum-roll), it still doesn't work - objects instantiated with
'radians' variables fail the check. The problem is the value
passed to the constructor is not aliased, so we end up losing the tie.
This would also be the case with paramaters in subroutine signatures.
Our contents might be correct but the isa radians tied tag
magic is lost - magic does not survive assignment.
This makes perfect sense, of course. You might imagine creating a tied iterator. If the magic carried over in assignments, you would end up leaving iterators everywhere, which is almost certainly not what you want.
We could proceed by not using signatures, and addressing @_ directly, or
by using a prototype to ensure values are passed by reference, or by
refaliasing,
but these would all add warts to the API, and would not work for fields.
As for why I didn't think of this while needing to use aliased values in all steps of the implementation to this point, you can make up your own insults for me here - I suggest "bumbling oaf" as a jumping-off point.
Oh well.
Conclusion
This post explored the idea of knocking together a robust type tagging and continuous value checking system over an idle evening. This failed to yield functioning, robust, or consistent results.
While tying variables to tags, on the surface, allowed assignements to be validated,
a number of problems cropped up while trying to apply existing value checking systems to these
tied variables. A variety of circumstances can cause the tie to be lost, in the absence of
easy ways to alias values without resorting to using prototypes or references everywhere.
Using the right
:Checked() invocation wasn't immediately obvious - it looked different to how
exisiting checks are used, requiring stringification. This can be solved, though it doesn't seem especially
worthwhile currently.
The coerce() (mis)feature introduced further problems. Even if care is taken,
invalid assigned values may still pass checks.
Value checking, type tagging, and coercion are not trivial problems. Trying to cram all three into a single thing over the course of an evening's noodling is, I have to admit, beyond me... and I was so hopeful!
My recommendation if you want value metadata is to use the tools which already
exist.
A Radians class could do everything described here. Its "tag" can be checked
by InstanceOf,
its value by Num. Mutation, if required, can be mediated by methods with
their own :Checked() clauses, using existing checks from the
Data::Checks and Types::Standard ecosystems.
This approach does not require invisible magic and aliasing-all-the-way-down. Classes are a common
and well-understood idiom, so people will be able to read and comprehend
your code with ease.
I did find this whole exercise enlightening. I hope you did too. I haven't abandoned this idea completely. If any other terrible plans come to mind, I shall post an update with haste.
Hasty Update (2026-08-24)
There are a handful of things you can do to maybe make this work, but I still don't recommend any of it:
- Fix the bug where existing values in the passed scalar aren't validated during
tie.- ... Which probably requires default values support for scalars declared without a value.
- Use Signature::Attribute::Alias to ensure scalar parameters remain tied in sub/method calls.
- This should be usable for class methods and mutators but checked fields remain a mystery, outside using references where required... I mean, "required".
That's all for now!
Hastier Update (2026-08-25)
Source code for reference only. Bad things will happen if you use this.