fuzzix dot org

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 exception

The 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 dies

The 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 = 3

Let'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; # 127

So 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; # 127

Not 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:

That's all for now!

Hastier Update (2026-08-25)

Source code for reference only. Bad things will happen if you use this.