Skip to content

cwd-k2/typist

Repository files navigation

Typist

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

Documentation

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

Synopsis

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;
}

Features

For the complete type system reference, see the Guide.

Type System

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

Analysis

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)

Modes

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

Installation

Requirements

  • Perl 5.40+
  • PPI (automatically resolved by any of the methods below)

From GitHub (cpanm)

cpanm https://github.com/cwd-k2/typist.git

From GitHub (Carton)

Add to your cpanfile:

requires 'Typist', git => 'https://github.com/cwd-k2/typist.git';

Then:

carton install

From source

git clone https://github.com/cwd-k2/typist.git
cd typist
perl Makefile.PL
make
make test
make install  # Installs typist-check and typist-lsp

CLI Tools

typist-check

Static 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 too

Output 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.

Annotation Syntax

Typist uses a single unified :sig(...) attribute for all type annotations.

Variables

my $x :sig(Int) = 42;
my $y :sig(Str | Undef) = undef;
my $z :sig({ name => Str, age => Int }) = { name => "Alice", age => 30 };

Functions

# 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) { }

Struct Types (Nominal)

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 derive

Struct types are nominal: Struct <: Record (structural compatibility), but Record </: Struct (nominal barrier).

Nominal Types (Newtype)

BEGIN {
    newtype UserId  => 'Int';
    newtype Email   => 'Str';
}

my $uid = UserId(42);       # Constructor validates inner type
my $raw = UserId::coerce($uid);  # Extracts inner value: 42

Algebraic Data Types

BEGIN {
    datatype Shape =>
        Circle    => '(Int)',
        Rectangle => '(Int, Int)',
        Point     => '';

    # Nullary constructors model enumerations
    datatype Color => Red => '()', Green => '()', Blue => '()';
}

my $c = Circle(5);          # Auto-generated constructor

GADT 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]';
}

Effects and Handlers

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 },
};

Effect Protocols (Stateful Effects)

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.

Type Classes

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 } };

Declare and Suppress

# 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
}

Gradual Typing

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)

Editor Integration

LSP Server

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)

Neovim (nvim-lspconfig)

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 {}

VS Code

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.vsix

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

Perl::Critic Policies

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

Environment Variables

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

Examples

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

Testing

# 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 policy

Known Limitations

For detailed analysis internals, see the Static Analysis Internals.

  • Type inference — Literal types are widened to base atoms for unannotated mutable bindings (my $x = 0Int, my $x = 3.14Double). 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/handle body 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.

License

MIT License. See LICENSE for details.

Contributors

Languages