A pure Perl type system for Perl 5.40+.
Typist brings static type annotations to Perl through standard attribute syntax. Errors are caught at compile time (CHECK phase) and via LSP — no source filters, no external tooling, no runtime cost by default.
`:sig(...)` attribute
|
+----------+------+------+----------+
| | | |
CHECK phase CLI Check LSP Server Runtime (opt-in)
(compile) (terminal) (editor) (-runtime flag)
| | | |
warn STDERR exit code Diagnostics die on mismatch
Full documentation is hosted at docs/, structured for GitHub Pages with MkDocs Material.
| Section | Audience | Content |
|---|---|---|
| Getting Started | Users | Installation, first program, editor setup |
| Guide | Users | Type annotations, type hierarchy, generics, effects, and more |
| Advanced | Users | HKT, rank-2, narrowing, subtyping rules |
| Cookbook | Users | Domain modeling, error handling, multi-file projects |
| Tooling | Users | typist-check, LSP, Perl::Critic, debug tools |
| Reference | Users | Type syntax grammar, prelude, diagnostics |
| Internals | Contributors | Architecture, static analysis, conventions, LSP coverage |
use Typist;
# Type aliases and records (structural)
BEGIN {
typedef Name => 'Str';
typedef Config => '{ host => Str, port => Int }';
}
# Nominal struct types (blessed, immutable)
BEGIN {
struct Person => (name => 'Str', age => 'Int', optional(email => 'Str'));
}
my $p = Person(name => "Alice", age => 30);
$p->name; # getter
$p->with(age => 31); # immutable update
# Typed variables
my $count :sig(Int) = 0;
my $label :sig(Maybe[Str]) = undef;
# Typed subroutines — unified :sig() annotation
sub add :sig((Int, Int) -> Int) ($a, $b) {
$a + $b;
}
# Generics with bounded quantification
sub max_of :sig(<T: Num>(T, T) -> T) ($a, $b) {
$a > $b ? $a : $b;
}
# Algebraic effects with row polymorphism
BEGIN {
effect Console => +{
readLine => '() -> Str',
writeLine => '(Str) -> Void',
};
}
sub greet :sig((Str) -> Str ![Console]) ($name) {
"Hello, $name!";
}
# Row polymorphism — "at least Log, plus whatever r adds"
sub with_log :sig(<r: Row>(Str) -> Str ![Log, r]) ($msg) {
$msg;
}For the complete type system reference, see the Guide.
| Feature | Syntax | Example |
|---|---|---|
| Primitive types | Int, Str, Double, Num, Bool, Any, Void, Never, Undef |
my $x :sig(Int) = 42 |
| Parameterized types | Name[T, ...] |
ArrayRef[Int] (Array[Int]), HashRef[Str, Int] (Hash[Str, Int]) |
| Union / Intersection | A | B, A & B |
Int | Str, Readable & Writable |
| Function types | (A, B) -> R |
(Int, Int) -> Int |
| Struct (nominal) | struct Name => (fields) |
Blessed immutable objects with accessors |
| Record (structural) | { k => T, k? => T } |
{ name => Str, age? => Int } |
| Maybe | Maybe[T] |
Maybe[Str] = Str | Undef |
| Literal types | 42, "hello" |
Singleton types for specific values |
| Type aliases | typedef |
typedef Price => 'Int' |
| Nominal types | newtype / Name::coerce |
newtype UserId => 'Int' |
| ADT / GADT | datatype |
Tagged unions, per-constructor return types |
| Enumerations | datatype (nullary) |
datatype Color => Red => '()', ... |
| Generics | <T>, <T: Bound> |
<T: Num>(T, T) -> T |
| Rank-2 polymorphism | forall |
forall A. (A) -> A |
| Variadic functions | ...Type |
(Int, ...Str) -> Void |
| Type classes / HKT | typeclass / instance |
Ad-hoc polymorphism, F: * -> * |
| Algebraic effects | effect / ![...] |
![Console, Log] |
| Effect protocols | protocol('sig', 'From -> To') |
![DB<* -> Authed>] |
| Row polymorphism | <r: Row> / [E, r] |
Effect row extension |
| Effect handlers | Effect::op(...) / handle |
Direct dispatch + scoped handling |
| Feature | Description |
|---|---|
| Opt-in CHECK analysis | Type/effect errors at compile time via warn when -check or TYPIST_CHECK=1 is enabled |
| CLI checker | Terminal-based static analysis with colored output |
| LSP server | Hover, completion, diagnostics, go-to-definition, references, rename, code actions, semantic tokens, and more |
| Cross-file checking | Workspace-level type resolution across modules (aliases, newtypes, datatypes, structs, effects, typeclasses, instances) |
| Gradual typing | Annotation density determines check strictness |
| Type inference | Bidirectional inference, literal widening for mutable bindings, control flow narrowing (defined, truthiness, isa, ref(), early return) |
| Builtin prelude | 80 builtins with type annotations and three standard effect labels (IO, Exn, Decl) |
| Mode | Cost | Behavior |
|---|---|---|
Default (use Typist) |
Near-zero runtime overhead | Runtime helpers only; use typist-check, LSP, or -check for static diagnostics |
CHECK (-check) |
Compile-time analysis only | warn diagnostics at CHECK phase |
Runtime (-runtime) |
Per-call type checks via tie + sub wrapping |
die on type violation |
| Newtype boundary | Always active | Constructor validation regardless of mode |
- Perl 5.40+
- PPI (automatically resolved by any of the methods below)
cpanm https://github.com/cwd-k2/typist.gitAdd to your cpanfile:
requires 'Typist', git => 'https://github.com/cwd-k2/typist.git';Then:
carton installgit clone https://github.com/cwd-k2/typist.git
cd typist
perl Makefile.PL
make
make test
make install # Installs typist-check and typist-lspStatic type checker for the terminal. Uses the same analysis engine as the LSP server.
typist-check # Scan lib/ for .pm files
typist-check lib/Shop/Order.pm # Check specific file(s)
typist-check --root src/ # Custom workspace root
typist-check --no-color # Disable colored output
typist-check --verbose # Show clean files tooOutput example:
lib/Shop/Order.pm
42:5 error expected Int, got Str in argument 1 [TypeMismatch]
58:1 error wrong number of arguments [ArityMismatch]
lib/Shop/Payment.pm
17:1 warning undeclared type variable 'T' [UndeclaredTypeVar]
2 error(s), 1 warning(s) in 2 file(s) (4 files checked)
Exit codes: 0 = clean, 1 = errors, 2 = warnings only.
Color is disabled automatically when stdout is not a TTY, --no-color is passed, or NO_COLOR is set.
Typist uses a single unified :sig(...) attribute for all type annotations.
my $x :sig(Int) = 42;
my $y :sig(Str | Undef) = undef;
my $z :sig({ name => Str, age => Int }) = { name => "Alice", age => 30 };# Parameters and return type
sub add :sig((Int, Int) -> Int) ($a, $b) { $a + $b }
# With effects
sub greet :sig((Str) -> Str ![Console]) ($name) { "Hello, $name!" }
# With generics
sub first :sig(<T>(ArrayRef[T]) -> T) ($arr) { $arr->[0] }
# With bounded quantification
sub max_of :sig(<T: Num>(T, T) -> T) ($a, $b) { $a > $b ? $a : $b }
# Variadic arguments
sub log_all :sig((Str, ...Any) -> Void ![Console]) ($fmt, @args) { }BEGIN {
struct Person => (
name => Str,
age => Int,
email => optional(Str), # omittable field
);
}
my $p = Person(name => "Alice", age => 30);
$p->name; # "Alice"
Person::derive($p, age => 31); # immutable deriveStruct types are nominal: Struct <: Record (structural compatibility), but Record </: Struct (nominal barrier).
BEGIN {
newtype UserId => 'Int';
newtype Email => 'Str';
}
my $uid = UserId(42); # Constructor validates inner type
my $raw = UserId::coerce($uid); # Extracts inner value: 42BEGIN {
datatype Shape =>
Circle => '(Int)',
Rectangle => '(Int, Int)',
Point => '';
# Nullary constructors model enumerations
datatype Color => Red => '()', Green => '()', Blue => '()';
}
my $c = Circle(5); # Auto-generated constructorGADT constructors specify per-constructor return types:
BEGIN {
datatype 'Expr[A]' =>
IntLit => '(Int) -> Expr[Int]',
BoolLit => '(Bool) -> Expr[Bool]',
Add => '(Expr[Int], Expr[Int]) -> Expr[Int]';
}BEGIN {
effect Console => +{
readLine => '() -> Str',
writeLine => '(Str) -> Void',
};
}
# Function declares its effects
sub io_greet :sig((Str) -> Void ![Console]) ($name) { say "Hi, $name" }
# Effect operations are called as qualified subs
Console::writeLine("hello");
# handle installs scoped handlers and executes a body
my $result = handle {
Console::writeLine("start");
42;
} Console => +{
writeLine => sub ($msg) { say $msg },
};Effects can carry protocol state machines that enforce operation ordering:
BEGIN {
# * is the ground state (protocol inactive); explicit states are active states only
effect Database => qw/Connected Authed/ => +{
connect => protocol('(Str) -> Void', '* -> Connected'),
auth => protocol('(Str, Str) -> Void', 'Connected -> Authed'),
query => protocol('(Str) -> Str', 'Authed -> Authed'),
disconnect => protocol('() -> Void', 'Authed -> *'),
};
}
# State transitions are declared in type annotations
sub setup :sig(() -> Void ![Database<* -> Authed>]) () {
Database::connect("localhost"); # * → Connected
Database::auth("user", "pass"); # Connected → Authed
}
# Invariant state: start and end in the same state
sub run_query :sig((Str) -> Str ![Database<Authed>]) ($sql) {
Database::query($sql); # Authed → Authed
}
# ![Database] defaults to * -> * (full session cycle)
sub session :sig(() -> Str ![Database]) () {
setup();
my $r = run_query("SELECT 1");
Database::disconnect();
$r;
}The static analyzer traces operation sequences and verifies that the final state matches the declared end state. Calling DB::query from state * produces a ProtocolMismatch diagnostic.
BEGIN {
typeclass Show => T, +{
show => '(T) -> Str',
};
instance Show => Int, +{
show => sub ($x) { "$x" },
};
}
say Show::show(42); # "42"Instances can be defined in a separate file from their typeclass. The LSP workspace and typist-check resolve instances across modules:
# lib/MyApp/Classes.pm
typeclass Eq => T, +{ eq => '(T, T) -> Bool' };
# lib/MyApp/Instances.pm — different file
instance Eq => Int, +{ eq => sub ($a, $b) { $a == $b } };
instance Eq => Str, +{ eq => sub ($a, $b) { $a eq $b } };# Annotate external functions for type/effect checking
declare say => '(Str) -> Void ![Console]';
sub handler :sig((Str) -> Str ![Console]) ($s) {
# @typist-ignore
some_unannotated_function($s); # Diagnostic suppressed
}For implementation details, see the Static Analysis Internals.
Typist enforces checks proportional to annotation density:
| Annotation Level | Type Checks | Effect Checks |
|---|---|---|
| Fully annotated | All params, return, call sites | Full effect inclusion |
| Partially annotated (no return) | Params only, return type unknown | As declared |
Partially annotated (no :Eff) |
As declared | Treated as pure |
| Completely unannotated | Skipped (Any -> Any) |
Treated as pure (no constraint) |
The standalone LSP server provides a comprehensive editing experience:
| Capability | Description |
|---|---|
| Diagnostics | Type mismatch, arity mismatch, effect mismatch, alias cycles |
| Hover | Type signatures for functions, variables, constructors, typedefs |
| Completion | Type annotation completion and type-aware code completion |
| Go to Definition | Same-file and cross-file definition lookup |
| Find References | Word-boundary search across open documents and workspace |
| Rename | Symbol rename across all workspace files |
| Signature Help | Function parameter hints with active parameter tracking |
| Document Symbols | Outline of functions, variables, typedefs, newtypes, datatypes, effects, typeclasses |
| Inlay Hints | Inferred types for unannotated variables, inferred effects for unannotated functions |
| Code Actions | Quick-fix suggestions for effect mismatches and type errors |
| Semantic Tokens | Syntax highlighting for Typist keywords, type names, constructors, and type parameters |
Launch the server:
typist-lsp # After make install
carton exec -- perl bin/typist-lsp # Development (carton)local configs = require('lspconfig.configs')
configs.typist = {
default_config = {
cmd = { 'typist-lsp' },
filetypes = { 'perl' },
root_dir = function(fname)
return vim.fs.dirname(
vim.fs.find({ 'lib', '.git' }, { upward = true, path = fname })[1]
)
end,
},
}
require('lspconfig').typist.setup {}A dedicated extension is provided at editors/vscode/.
Build and install:
cd editors/vscode
npm install
npm run build
npx vsce package
code --install-extension typist-0.0.1.vsixThe extension looks for local/bin/typist-lsp in the workspace root (e.g. installed via cpanm -L local Typist), then falls back to typist-lsp on $PATH. To override, set typist.server.path in VS Code settings.
Four policies for code quality enforcement:
| Policy | Description | Default Severity |
|---|---|---|
Typist::TypeCheck |
Static type checking via Typist analyzer | 2 |
Typist::AnnotationStyle |
Require :sig() on public subs |
2 |
Typist::EffectCompleteness |
Require effect declarations for effectful functions | 3 |
Typist::ExhaustivenessCheck |
Warn on non-exhaustive match expressions |
2 |
# .perlcriticrc
[Typist::TypeCheck]
severity = 2
[Typist::AnnotationStyle]
severity = 2
[Typist::EffectCompleteness]
severity = 3
[Typist::ExhaustivenessCheck]
severity = 2| Variable | Effect |
|---|---|
TYPIST_RUNTIME |
Enable runtime enforcement (1 = on) |
TYPIST_CHECK |
Enable CHECK-phase static analysis (1 = on) |
TYPIST_CHECK_QUIET |
Suppress CHECK-phase diagnostics when static analysis is enabled (1 = quiet) |
TYPIST_LSP_LOG |
LSP log level (off/error/warn/info/debug/trace) |
TYPIST_LSP_TRACE |
Path to JSONL trace file for LSP message recording |
NO_COLOR |
Disable colored output in typist-check |
See example/ for runnable demonstrations:
| File | Topics |
|---|---|
01_foundations.pl |
Type aliases, typed variables/functions |
02_composite_types.pl |
Struct, Union, Maybe, parameterized types |
03_generics.pl |
Generic functions, bounded quantification |
04_nominal_types.pl |
Newtypes, literal types, recursive types |
05_algebraic_types.pl |
Datatype/ADT, pattern matching |
06_typeclasses.pl |
Type classes, HKT, Functor |
07_effects.pl |
Effect system, handlers, protocols |
08_gradual_typing.pl |
Gradual typing, flow typing |
09_dsl.pl |
DSL operators, constructors |
10_higher_order.pl |
Higher-order function inference |
11_static_errors.pl |
Intentional type errors for static analysis demo |
12_method_chains.pl |
Struct accessors, immutable updates, newtype chains |
carton exec -- perl example/01_foundations.pl# All tests (66 files)
carton exec -- prove -l t/ t/static/ t/lsp/ t/critic/
# By category
carton exec -- prove -l t/ # Core type system
carton exec -- prove -l t/static/ # Static analysis
carton exec -- prove -l t/lsp/ # LSP server
carton exec -- prove -l t/critic/ # Perl::Critic policyFor detailed analysis internals, see the Static Analysis Internals.
- Type inference — Literal types are widened to base atoms for unannotated mutable bindings (
my $x = 0→Int,my $x = 3.14→Double). Operator precedence does not influence inferred types. - Method checking — Instance (
$self->method()), cross-package struct ($p->name()), class (Person->new()), chained (Name::derive($p, ...)->greet()), generic, and Record accessor calls are checked. Union receivers and untyped receivers are gradual-skipped. - Type narrowing — Supports
defined($x), truthiness,isa,ref($x) eq/ne 'TYPE'(with/without parens, variable comparison, inverse narrowing for Union types), and early return. Full ref type map:HASH,ARRAY,SCALAR,CODE,REF,Regexp,GLOB,IO,VSTRING, plus blessed class names. - Effect system — Effect inference provides LSP inlay hints for unannotated functions (direct callees only). Protocol checking traces operation sequences with if/else branching convergence, loop idempotency enforcement, and
match/handlebody tracking. - Typeclass instances — Cross-file instance declarations are extracted and registered for workspace resolution. Static analysis records instance existence but does not validate method completeness; completeness checking runs at runtime only. Method implementations (coderefs) are not available to the static path.
- PPI dependency — Diagnostic quality depends on PPI's parse accuracy.
MIT License. See LICENSE for details.