Gradual Migration¶
Typist is designed for incremental adoption. You do not need to annotate your entire codebase at once. This page walks through a step-by-step migration of existing Perl code, from zero annotations to a fully typed module.
Migration Strategy¶
The recommended order:
- Add
use Typist;to your modules -- runtime helpers only, no behavior change. - Define domain types --
typedef,newtype,structfor your data models. - Annotate public API functions -- start at module boundaries.
- Set up
typist-checkin CI -- catch regressions early. - Set up the LSP -- get diagnostics as you code.
- Annotate effects -- track I/O and exceptions.
- Work inward -- annotate internal functions as needed.
- Consider
-runtimefor tests -- catch boundary violations at test time.
Each step is independently valuable. You can stop at any point and still benefit from what you have annotated so far.
Gradual Typing Semantics¶
Typist enforces checks proportional to annotation density:
| Annotation level | What happens |
|---|---|
| Fully annotated | All type checks, effect checks, arity checks active |
| Partially annotated | Checked where annotations exist; untyped areas return Any |
| Completely unannotated | Treated as (Any...) -> Any; type checks skip; effects treated as pure |
The key principle: no annotation = no constraint. Adding use Typist without any :sig() annotations produces zero diagnostics and zero overhead.
Before: Untyped Module¶
Here is a typical Perl module before any Typist integration:
# lib/MyApp/Pricing.pm
package MyApp::Pricing;
use v5.40;
use Exporter 'import';
our @EXPORT_OK = qw(calculate_total apply_discount format_price);
sub calculate_total ($items) {
my $total = 0;
for my $item (@$items) {
$total += $item->{price} * $item->{quantity};
}
$total;
}
sub apply_discount ($total, $discount_pct) {
my $discount = int($total * $discount_pct / 100);
$total - $discount;
}
sub format_price ($cents) {
sprintf '$%.2f', $cents / 100;
}
1;
Nothing is wrong with this code. It works. But there is no way to know, from the source alone, what $items should contain, what types apply_discount expects, or what any of these functions return.
Step 1: Add use Typist¶
package MyApp::Pricing;
use v5.40;
use Typist; # <-- added
use Exporter 'import';
our @EXPORT_OK = qw(calculate_total apply_discount format_price);
# ... rest unchanged
This does nothing visible. No diagnostics, and almost no runtime cost. It enables :sig() attributes, runtime helpers, and the prelude. Static analysis remains explicit via typist-check, the LSP, or use Typist -check;.
Step 2: Define Domain Types¶
Add a BEGIN block with type definitions that describe your domain:
package MyApp::Pricing;
use v5.40;
use Typist;
use Exporter 'import';
our @EXPORT_OK = qw(calculate_total apply_discount format_price);
BEGIN { # <-- added
typedef LineItem => '{ price => Int, quantity => Int }';
}
# ... rest unchanged
For a larger project, put shared types in a dedicated module (see Multi-File Projects).
Step 3: Annotate Public Functions¶
Start with the module's public API -- the functions that callers depend on:
sub calculate_total :sig((ArrayRef[LineItem]) -> Int) ($items) {
my $total = 0;
for my $item (@$items) {
$total += $item->{price} * $item->{quantity};
}
$total;
}
sub apply_discount :sig((Int, Int) -> Int) ($total, $discount_pct) {
my $discount = int($total * $discount_pct / 100);
$total - $discount;
}
sub format_price :sig((Int) -> Str) ($cents) {
sprintf '$%.2f', $cents / 100;
}
Now the static checker can verify:
- Callers pass the right types to these functions.
- Return types match the declared signatures.
- $items elements are used consistently with the LineItem record shape.
Step 4: Strengthen Types with Newtypes¶
As confidence grows, promote raw types to newtypes for stronger guarantees:
BEGIN {
newtype Price => 'Int'; # cents
newtype Quantity => 'Int';
newtype Percent => 'Int'; # 0-100
struct LineItem => (
price => 'Price',
quantity => 'Quantity',
);
}
sub calculate_total :sig((ArrayRef[LineItem]) -> Price) ($items) {
my $total = 0;
for my $item (@$items) {
$total += Price::coerce($item->price) * Quantity::coerce($item->quantity);
}
Price($total);
}
sub apply_discount :sig((Price, Percent) -> Price) ($total, $discount_pct) {
my $raw = Price::coerce($total);
my $discount = int($raw * Percent::coerce($discount_pct) / 100);
Price($raw - $discount);
}
sub format_price :sig((Price) -> Str) ($cents) {
sprintf '$%.2f', Price::coerce($cents) / 100;
}
Now apply_discount(Price(1000), Quantity(5)) is a static error -- you cannot accidentally pass a quantity where a percentage is expected.
After: Fully Typed Module¶
The complete migrated module:
# lib/MyApp/Pricing.pm
package MyApp::Pricing;
use v5.40;
use Typist;
use Exporter 'import';
our @EXPORT_OK = qw(
Price Quantity Percent LineItem
calculate_total apply_discount format_price
);
BEGIN {
newtype Price => 'Int';
newtype Quantity => 'Int';
newtype Percent => 'Int';
struct LineItem => (
price => 'Price',
quantity => 'Quantity',
);
}
sub calculate_total :sig((ArrayRef[LineItem]) -> Price) ($items) {
my $total = 0;
for my $item (@$items) {
$total += Price::coerce($item->price) * Quantity::coerce($item->quantity);
}
Price($total);
}
sub apply_discount :sig((Price, Percent) -> Price) ($total, $discount_pct) {
my $raw = Price::coerce($total);
my $discount = int($raw * Percent::coerce($discount_pct) / 100);
Price($raw - $discount);
}
sub format_price :sig((Price) -> Str) ($cents) {
sprintf '$%.2f', Price::coerce($cents) / 100;
}
1;
Setting Up CI¶
Add typist-check to your CI pipeline to catch type regressions:
# GitHub Actions
- name: Type check
run: typist-check --no-color
# Or with specific files
- name: Type check
run: typist-check --no-color lib/MyApp/Pricing.pm lib/MyApp/Order.pm
Exit codes:
- 0 -- all clean
- 1 -- errors found (type mismatches, arity mismatches, etc.)
- 2 -- warnings only (undeclared type variables, unknown types, etc.)
Color is auto-disabled when stdout is not a TTY, so --no-color is optional in CI environments, but explicit is clearer.
Setting Up the LSP¶
Configure your editor to use typist-lsp for real-time feedback. See Editor Setup for Neovim, VS Code, and other editor configurations.
If you opt into CHECK-phase analysis with TYPIST_CHECK=1 or use Typist -check;, set TYPIST_CHECK_QUIET=1 to suppress duplicate output when the LSP is providing the same diagnostics inline.
Annotating Effects¶
Once your data types and function signatures are in place, add effect annotations to track side effects:
BEGIN {
effect Logger => +{
log => '(Str) -> Void',
};
}
sub process_order :sig((Order) -> Price ![Logger, IO]) ($order) {
Logger::log("Processing order " . $order->id);
my $total = calculate_total($order->items);
say "Order total: " . format_price($total);
$total;
}
The static checker will flag callers of process_order that do not declare Logger and IO in their own effect rows.
Using -runtime in Tests¶
For test suites, consider enabling runtime mode to catch violations at the boundary between typed and untyped code:
# t/pricing.t
use v5.40;
use Typist -runtime;
use MyApp::Pricing qw(Price Quantity LineItem calculate_total);
my $items = [
LineItem(price => Price(1000), quantity => Quantity(2)),
LineItem(price => Price(500), quantity => Quantity(3)),
];
my $total = calculate_total($items);
# Runtime mode validates that $total is actually a Price
This adds per-call type checking overhead but catches bugs at the exact point of violation, which is valuable in tests.
Tips for Gradual Migration¶
-
Start at the edges. Annotate module boundaries (public API functions) first. These are where type confusion most often manifests.
-
Let the tooling guide you. After annotating a few functions, run
typist-checkor open the file in your editor with the LSP. The diagnostics will point to the next places that need attention. -
Do not fight
Any. If the type checker infersAnyfor something, that is fine. It means the area is not yet annotated. Come back to it later. -
Newtypes pay for themselves immediately. Even a single
newtype UserId => 'Int'prevents an entire class of bugs. These are the highest-value annotations. -
One module at a time. Migrate one module fully before moving to the next. A partially annotated module still provides value at its boundaries.
-
Test
-runtimeselectively. Do not enable-runtimein production code. Use it in test files where the overhead is acceptable and the additional checking is valuable.