Typist Architecture¶
This document describes the internal architecture of Typist, including module dependencies, data flow, and design decisions.
Related documentation: Guide (type system) | static-analysis.md (analyzer algorithms) | conventions.md (coding conventions) | lsp-coverage.md (LSP features)
Table of Contents¶
- System Overview
- Lifecycle: From Source to Diagnostics
- Module Dependency Graph
- Type Node Hierarchy
- Static Analysis Pipeline
- Registry Design
- Error System
- LSP Server Architecture
- Runtime Enforcement
- Module Loading Strategy
System Overview¶
Typist operates across three phases of a Perl program's lifecycle:
Source Code
|
v
+==========================================+
| COMPILE TIME (Perl's BEGIN/use phase) |
| |
| use Typist; |
| -> import(): register package, |
| install attribute handlers, |
| export typedef/newtype/effect/ |
| datatype/handle/... |
| |
| :sig(...) attributes processed: |
| -> Parser->parse_annotation() |
| -> Registry->register_function() |
| -> Registry->register_method() |
| -> [runtime only] Attribute->_wrap() |
| |
| typedef/newtype/effect/typeclass/ |
| datatype: |
| -> Registry->define_alias() |
| -> Registry->register_newtype() |
| -> Registry->register_effect() |
| -> Registry->register_typeclass() |
| -> Registry->register_datatype() |
+==========================================+
|
v
+==========================================+
| CHECK PHASE (after compile, before |
| runtime execution) |
| |
| 1. Error::Global->reset() |
| 2. Static::Checker->analyze() |
| - Alias cycle detection |
| - Undeclared type variable check |
| - Kind well-formedness |
| - TypeClass superclass validation |
| 3. _check_analyze() |
| - require Static::Analyzer (lazy) |
| - For each registered package: |
| - Read source file |
| - Install Prelude (CORE defaults) |
| - PPI::Document->new() |
| - Extractor->extract() |
| - Register methods |
| - Register datatypes |
| - TypeChecker->analyze() |
| (assignments, arity, generics, |
| methods, narrowing, branches) |
| - EffectChecker->analyze() |
| 4. warn Error::Global->report() |
+==========================================+
|
v
+==========================================+
| RUNTIME |
| |
| Default (`use Typist`): |
| Original subs execute directly. |
| No tie, no wrappers, no static pass. |
| |
| Runtime mode (-runtime): |
| Tied scalars: STORE/FETCH validate. |
| Wrapped subs: args + return checked. |
| |
| Always active (structural): |
| Constructor arity/field checks. |
| Name::coerce($val) extracts inner. |
| Effect::op() dispatches effect ops. |
| handle { } scoped handler blocks. |
+==========================================+
Lifecycle: From Source to Diagnostics¶
The :sig() Attribute Journey¶
When Perl compiles sub add :sig((Int, Int) -> Int) ($a, $b) { ... }:
Perl compiler encounters :sig(...)
|
v
MODIFY_CODE_ATTRIBUTES callback (Attribute.pm)
|
+-> Parser->parse_annotation("(Int, Int) -> Int")
| |
| +-> Tokenize: ['(', 'Int', ',', 'Int', ')', '->', 'Int']
| +-> Recursive descent: Func([Atom(Int), Atom(Int)], Atom(Int))
| +-> Return: { type => Func(...), generics_raw => [] }
|
+-> Detect method: first param $self → register_method
| Detect function: otherwise → register_function
|
+-> Registry->register_function('main', 'add', {
| params => [Atom(Int), Atom(Int)],
| returns => Atom(Int),
| ...
| })
|
+-> [if $RUNTIME] _wrap_sub($coderef, $sig, $pkg, $name)
| |
| +-> Replace glob entry with validation closure
|
v
Attribute returns () — accepted
The CHECK Phase Pipeline¶
CHECK block fires (Typist.pm)
|
+-> Error::Global->reset()
|
+-> Static::Checker->new->analyze()
| |
| +-> _check_aliases() # Resolve each alias, detect cycles
| +-> _check_functions() # Free vars, bounds, kinds per function
| +-> _check_typeclasses() # Superclass existence + cycle detection
|
+-> _check_analyze() [unless $CHECK_QUIET]
|
+-> require Static::Analyzer (first PPI load)
|
+-> for each package in Registry->all_packages:
|
+-> _package_to_file() # main -> $0, Foo::Bar -> $INC{"Foo/Bar.pm"}
+-> _slurp($file) # Read source
|
+-> Analyzer->analyze($source, workspace_registry => ...)
|
+--[ Prelude Phase ]--------+
| Prelude->install(registry) |
| Register IO, Exn, Decl |
| Register CORE:: builtins |
+----------------------------+
|
+--[ Extraction Phase ]------+
| Extractor->extract($src) |
| PPI::Document->new(\$s) |
| Extract: aliases, |
| newtypes, datatypes, |
| effects, typeclasses, |
| declares, variables, |
| functions (with method |
| detection), ignore lines|
+----------------------------+
|
+--[ Registration Phase ]----+
| Register typedefs |
| Register newtypes |
| Register datatypes |
| Register effects |
| Register typeclasses |
| Register declares |
| Register functions |
| Register methods |
+----------------------------+
|
+--[ Visibility Phase ]------+
| ImportHint diagnostics |
| (type provenance check: |
| is_type_visible per ann) |
+----------------------------+
|
+--[ Check Phase ]------------+
| Checker->analyze() |
| TypeChecker->analyze() |
| _check_variable_init |
| _check_assignments |
| CallChecker (delegate) |
| (arity, generic unify, |
| method/struct calls) |
| _check_return_types |
| (explicit + implicit |
| branch analysis) |
| NarrowingEngine |
| (defined/isa/ref guards,|
| early return, accessor)|
| EffectChecker->analyze() |
+-----------------------------+
|
+-> Return { diagnostics, symbols, extracted, registry }
Module Dependency Graph¶
Core Type System¶
Typist::Type (abstract base, overloads)
|
+------+------+------+---+---+------+--------+--------+------+
| | | | | | | | |
Atom Param Union Intersect Func Record Struct Var Quantified
| | | | |
(pool) (effects)(structural)(nominal) (forall)
+--------+--------+-------+-------+-------+-------+
| | | | | | |
Alias Literal Newtype Data Row Eff Fold
| | | |
(variants)(labels)(wraps Row)(traversal)
Module Loading DAG¶
Typist.pm (entry point)
|
+-- Type::* (16 modules) Always loaded
| +-- Type::Data Tagged unions (ADT)
| +-- Type::Quantified Rank-2 polymorphism (forall)
| +-- Type::Fold map_type / walk traversals
+-- Effect, TypeClass Always loaded
+-- Parser Always loaded
+-- Registry Always loaded
+-- Handler Always loaded (effect handler stack)
+-- Attribute Always loaded (require, not use)
+-- Error, Error::Global Always loaded
|
+-- Kind, KindChecker DEFERRED (via Attribute._ensure_deps)
+-- Subtype DEFERRED (via Attribute._ensure_deps)
+-- Inference DEFERRED (via Attribute._ensure_deps)
+-- B, Transform, Tie::Scalar DEFERRED (via Attribute._ensure_deps)
+-- Static::Checker DEFERRED (require in CHECK phase)
| +-- Type::Fold
|
+-- Static::Analyzer LAZY (require in CHECK)
| +-- Prelude Builtin type annotations
| +-- Static::Extractor
| | +-- PPI <-- Heavy dependency, lazy
| +-- Static::TypeEnv Env construction (has submodules)
| +-- Static::TypeChecker Var/return checks, coordination
| | +-- Static::CallChecker Call-site type checking
| | +-- Static::NarrowingEngine Control flow narrowing
| | +-- Static::Infer Type inference (has submodules)
| | +-- Static::Unify Generic instantiation
| +-- Static::EffectChecker
| +-- Static::ProtocolChecker
|
+-- Prelude LAZY (via Analyzer, Workspace)
LSP Module Graph¶
Typist::LSP (entry point, bin/typist-lsp)
|
+-- LSP::Server
| +-- LSP::Transport JSON-RPC framing
| +-- LSP::Document Per-file analysis cache (has partials)
| +-- LSP::Workspace Cross-file registry
| | +-- Prelude Builtin annotations for workspace
| +-- LSP::Hover Type signature display
| +-- LSP::Completion Type name suggestions
| +-- LSP::CodeAction Quickfix suggestions
| +-- LSP::SemanticTokens Syntax-aware token classification
| +-- LSP::Logger Configurable logging
| +-- Static::Analyzer Full analysis pipeline
| +-- (all Static::* modules)
|
+-- Registry (instance-based)
+-- Error::Collector (instance-based)
Type Node Hierarchy¶
Abstract Interface¶
Every Typist::Type subclass implements:
Method Returns Purpose
────────────── ────────────── ────────────────────────────────
name() Str Type name (e.g., "Int", "ArrayRef")
to_string() Str Human-readable representation
equals($other) Bool Structural equality
contains($val) Bool Runtime value membership test
free_vars() List[Str] Unbound type variable names
substitute(\%) Type Apply binding map, return new type
Type Lattice¶
Any
/ | \ \
Str Num | Void Undef
| |
Double |
| |
Int |
| |
Bool |
| |
Never
Structural:
Param[T...] ArrayRef[Int] (Array[Int]), HashRef[Str, Num] (Hash[Str, Num])
Union(T|U) Int | Str
Intersection(T&U) Readable & Writable
Func(P->R!E) (Int, Int) -> Int ![Console]
Record{k:T} { name => Str, age? => Int } — structural composite
Nominal:
Struct(name,fields) struct 'Point' => (x => Int, y => Int) — nominal composite
Newtype(name,T) Nominal wrappers — name-based identity
Data(name,vars) Tagged unions — nominal ADT with constructors
Alias(name) typedef references — lazy resolution
Literal(val,base) 42:Int, "hi":Str — singleton types
Quantification:
Quantified(vars,body) forall A. (A) -> A — rank-2 polymorphism
Var(name,bound,kind) T, U:Num, F:*->*
Effect:
Row(labels,var) Sorted effect labels + optional tail var
Eff(Row) Wrapper for function effect annotations
Meta:
Fold map_type (bottom-up), walk (top-down) — traversal utility
Subtyping Rules¶
Rule Notation Implementation
──────────────────── ──────────────────────── ──────────────────
Identity T <: T equals()
Top T <: Any Always true
Bottom Never <: T Always true
Void Void <: Any only No other supertypes
Alias resolve then compare Registry lookup
Union (sub) T|U <: S iff T<:S & U<:S All members subtype
Union (super) S <: T|U iff S<:T | S<:U Any member subtype
Intersection (sub) T&U <: S iff T<:S | U<:S Any member subtype
Intersection (super) S <: T&U iff S<:T & S<:U All members subtype
Newtype N <: N iff same name Nominal identity
Data D <: D iff same name Nominal identity
Literal-Literal L1 <: L2 iff val= & base<: Value + hierarchy
Literal-Atom L <: A iff L.base <: A Promotion
Atom A <: B iff A in ancestors(B) %PARENT chain (Bool<:Int<:Double<:Num<:Any)
Param P[A] <: P[B] iff A<:B Covariant
Func params (A)->R <: (B)->R iff B<:A Contravariant
Func return (A)->R <: (A)->S iff R<:S Covariant
Func effects ..!E1 <: ..!E2 iff E1<:E2 Covariant
Record {a,b,c} <: {a,b} Width subtyping (structural)
Struct S <: S iff same name Nominal identity (covariant args)
Struct-Record S <: {a,b} via inner record Structural compatibility
Record-Struct {a,b} </: S Nominal barrier
Quantified (forall A. T) <: U Instantiation
Row Row(A,B) <: Row(A) Label inclusion
Static Analysis Pipeline¶
Analyzer Orchestration (per-file)¶
Analyzer.analyze($source, workspace_registry => $ws_reg)
|
+-- 1. Merge workspace registry
| $registry->merge($ws_reg)
|
+-- 1b. Install Prelude
| Prelude->install($registry)
| +-> Register IO, Exn effect labels
| +-> Register CORE:: builtin annotations
|
+-- 2. Extract from PPI
| Extractor->extract($source)
| -> { aliases, newtypes, datatypes, effects, typeclasses,
| declares, variables, functions (with method detection),
| ignore_lines, package, ppi_doc }
|
+-- 3. Register extracted data into local registry
| for each typedef: registry->define_alias(name, expr)
| for each newtype: registry->register_newtype(name, type)
| for each datatype: registry->register_datatype(name, type)
| for each effect: registry->register_effect(name, eff)
| for each typeclass: registry->register_typeclass(name, def)
| for each declare: registry->register_function(pkg, name, sig)
| for each function: registry->register_function(pkg, name, sig)
| for each method: registry->register_method(pkg, name, sig)
|
+-- 4. Structural checks
| Checker->new(registry => $registry, errors => $errors)
| ->analyze()
|
+-- 5. Type mismatch checks
| TypeChecker->new(
| extracted => $extracted,
| registry => $registry,
| ppi_doc => $ppi_doc,
| errors => $errors,
| file => $file,
| )->analyze()
|
+-- 6. Effect mismatch checks
| EffectChecker->new(
| extracted => $extracted,
| registry => $registry,
| ppi_doc => $ppi_doc,
| errors => $errors,
| file => $file,
| )->analyze()
|
+-- 7. Enrich diagnostics with file/line info
| _to_diagnostics($errors, $extracted, $file)
|
+-- 8. Return result
{ diagnostics, symbols, extracted, registry }
TypeChecker Internal Flow¶
TypeChecker->analyze()
|
+-- _build_env()
| |
| +-- Load annotated variables into env.variables
| +-- Load function return types into env.functions
| +-- Mark annotated names in env.known
| +-- Infer unannotated variable types via Infer->infer_expr()
|
+-- _check_variable_initializers()
| |
| for each variable with init_node:
| inferred = Infer->infer_expr(init_node, env)
| declared = resolve(type_expr)
| if !Subtype->is_subtype(inferred, declared):
| collect TypeMismatch
|
+-- _check_assignments()
| |
| for each '=' operator in document:
| skip unless annotated variable, skip variable declarations
| inferred = Infer->infer_expr(RHS, env)
| if !Subtype->is_subtype(inferred, declared):
| collect TypeMismatch
|
+-- _check_call_sites()
| |
| for each PPI::Token::Word in document:
| if preceded by ->: delegate to _check_method_call
| resolve: local function / cross-package / CORE builtin
| skip if not a function call (no following List)
| arity check (ArityMismatch if wrong count)
| if generic: delegate to _check_generic_call (Unify)
| env = _env_for_node(word) # scoped + narrowed
| for each arg up to min(params, args):
| inferred = Infer->infer_expr(arg, env)
| if !Subtype->is_subtype(inferred, param_type):
| collect TypeMismatch
|
+-- _check_return_types()
|
for each function with returns_expr and block:
explicit returns: find 'return' keywords, check each
implicit return: _check_implicit_return_of_stmt
recursively walks if/else/while/for branches
EffectChecker Internal Flow¶
EffectChecker->analyze()
|
for each annotated function (skip unannotated):
|
caller_eff = registry->lookup_function(pkg, name).effects
|
+-- _collect_called_effects(function_block)
| |
| for each PPI::Token::Word in block:
| skip keywords, sub names, method calls, hash keys
| |
| if builtin (say, print, die, ...):
| check CORE registry (Prelude or declare):
| declared with effects → use those
| declared pure → skip
| no declaration → unannotated => 1
| |
| if local/cross-package function with arg list:
| lookup in registry
| return { name, effects, unannotated, line }
|
+-- for each callee:
if callee.unannotated (row_var '*'):
skip (treated as pure, no constraint)
if caller has no effects but callee does:
collect EffectMismatch (pure calls effectful)
if both have closed rows:
_check_effect_inclusion(caller_row, callee_row)
if callee labels not subset of caller labels:
collect EffectMismatch
Type Inference Capabilities¶
Expression Form Inferred Type Module
─────────────────────────── ──────────────────── ─────────────
42, 0, 1 Literal(val, base) Infer._infer_number (Bool/Int)
3.14, 1e10 Literal(val,'Double') Infer._infer_number
"hello", 'world' Literal(val, 'Str') Infer
"Hello, $name" Atom('Str') Infer (interpolated → Str)
<<HEREDOC Atom('Str') Infer
undef Atom('Undef') Infer
[1, 2, 3] Param('ArrayRef', T) Infer._infer_array
+{ k => v } Param('HashRef',S,T) Infer._infer_hash
$variable env lookup Infer
$arr->[0] ArrayRef[T] → T Infer._infer_subscript
$h->{k} HashRef → V / Struct Infer._infer_subscript
func(args) env.functions{func} Infer._infer_call
CORE::name(args) Prelude returns type Infer._infer_call
$a + $b Atom('Num') Infer._infer_binop
$a . $b Atom('Str') Infer._infer_binop
$a == $b Atom('Bool') Infer._infer_binop
$a =~ /pat/ Atom('Bool') Infer._infer_binop
!$x Atom('Bool') Infer._infer_operator
$x ? $a : $b LUB or Union Infer._infer_ternary
NOT SUPPORTED:
@{$arr}, %{$hash} - Dereference
Registry Design¶
Dual-Mode Operation¶
The Registry supports both class methods (singleton, for CHECK phase and runtime) and instance methods (for LSP, per-workspace):
Class Mode (Singleton) Instance Mode
───────────────────── ──────────────
Typist::Registry->register(...) $reg = Typist::Registry->new
| $reg->register(...)
v |
$DEFAULT //= Typist::Registry->new v
$DEFAULT->{...} $self->{...}
The _self helper dispatches: ref $invocant ? $invocant : $invocant->_default.
Storage Structure¶
Registry
|
+-- {packages} { "main" => 1, "Foo::Bar" => 1 }
+-- {aliases} { "Name" => "Str", "Config" => "{ host => Str }" }
+-- {resolved} { "Name" => Atom(Str) } # Cache
+-- {resolving} { "Name" => 1 } # Cycle guard
+-- {variables} { "$ref_addr" => { type => ..., name => ... } }
+-- {functions} { "main::add" => { params => [...], returns => ..., effects => ... } }
+-- {methods} { "Pkg::name" => { params => [...], returns => ..., ... } }
+-- {newtypes} { "UserId" => Newtype("UserId", Atom(Int)) }
+-- {datatypes} { "Shape" => Data("Shape", { Circle => [...], ... }) }
+-- {typeclasses} { "Show" => TypeClass::Def { ... } }
+-- {instances} { "Show" => [TypeClass::Inst { type_expr => "Int", ... }, ...] }
+-- {effects} { "Console" => Effect { name => "Console", operations => {...} } }
Cross-File Support via Merge¶
Workspace Registry (shared) Local Registry (per-file)
| |
| merge($ws_reg) |
+------------------------------>+
| copies: aliases, newtypes, |
| datatypes, effects, |
| typeclasses, functions, |
| methods, instances |
| clears: resolved (cache) |
|
Analyzer uses local
registry for all lookups
Error System¶
Two-Tier Design¶
Error (value class) Error::Collector (instance)
| |
+-- kind: Str +-- @errors: [Error, ...]
+-- message: Str +-- collect(%args): push
+-- file: Str +-- has_errors(): Bool
+-- line: Int +-- report(): Str
+-- reset(): clear
Error::Global (singleton)
|
+-- @ERRORS: package-scoped
+-- Same API as Collector
+-- Used by CHECK phase
+-- LSP uses Collector instances instead
Diagnostic Kinds and Severities¶
Severity 1 (Critical):
CycleError Alias or typeclass inheritance cycle
Severity 2 (Error):
TypeMismatch Value/argument type doesn't match declaration
ArityMismatch Wrong number of arguments at call site
TypeError General type error
ResolveError Cannot resolve type reference
UnknownTypeClass Referenced typeclass not found
EffectMismatch Callee effects not covered by caller
ProtocolMismatch Effect protocol state transition violation
Severity 3 (Warning):
UndeclaredTypeVar Type variable not in generic list
UndeclaredRowVar Row variable not in generic list
UnknownEffect Effect label not registered
InvalidBound Malformed bound expression
KindError Kind mismatch in type application
Severity 4 (Info):
UnknownType Referenced type not found (low severity)
ImportHint Type used but defining package not imported
Severity 5 (Hint, verbose-only):
GradualHint Type check skipped due to Any
LSP Server Architecture¶
Message Flow¶
Editor (Neovim/VS Code)
|
| stdin (JSON-RPC, Content-Length framing)
v
Transport (read_message / write_message)
|
v
Server (dispatch loop)
|
+-- initialize -> capabilities, workspace scan
+-- textDocument/didOpen -> Document.analyze -> publish diagnostics
+-- textDocument/didChange -> Document.invalidate -> re-analyze
+-- textDocument/didSave -> re-diagnose all open docs (cross-file)
+-- textDocument/hover -> Hover.hover(symbol)
+-- textDocument/completion -> Completion.complete(ctx, typedefs, effects, ...)
+-- textDocument/definition -> Definition lookup
+-- textDocument/signatureHelp -> Signature display
+-- textDocument/documentSymbol -> Symbol list
+-- textDocument/inlayHint -> Inlay hints
+-- shutdown / exit -> clean shutdown
|
| stdout (JSON-RPC responses)
v
Editor
Workspace Scanning¶
Workspace.scan(root_path)
|
+-- Install Prelude into registry
|
+-- File::Find all *.pm under root
|
+-- for each file:
| Extractor->extract(source)
| _register_file_types(extracted)
| +-- register aliases
| +-- register newtypes
| +-- register datatypes
| +-- register effects
| +-- register typeclasses
| +-- register declares
| +-- register functions (with parsed types)
| +-- register methods
| Store extracted data for rebuild
|
+-- Workspace.update_file(uri, source) [on save]
+-- delete stale file entry
+-- _rebuild_registry() # re-register from all stored
+-- process new source
Document Lifecycle¶
didOpen(uri, text)
+-> Document.new(uri, text, workspace)
+-> Document.analyze()
| +-> Analyzer->analyze(text, workspace_registry => ws.registry)
| +-> Cache result (diagnostics, symbols, extracted)
+-> Server.publish_diagnostics(uri, diagnostics)
didChange(uri, changes)
+-> Document.update(text)
+-> Document.invalidate()
didSave(uri)
+-> Workspace.update_file(uri, text)
+-> For each open document:
Document.invalidate()
Document.analyze()
Server.publish_diagnostics(uri, diagnostics)
Runtime Enforcement¶
Tied Scalar (Typist::Tie::Scalar)¶
tie $$ref, 'Typist::Tie::Scalar', type => $type, value => $initial
STORE($value):
$type->contains($value) or die
$self->{value} = $value
FETCH():
return $self->{value}
Wrapped Sub (Attribute._wrap_sub)¶
Original: sub add($a, $b) { $a + $b }
Wrapped:
sub {
my @args = @_;
# Generic instantiation (if generic)
if (@generics) {
@types = map { Inference->infer_value($_) } @args;
$bindings = Inference->instantiate($sig, @types);
# Check bounds
for each generic with bound:
die unless Subtype->is_subtype($actual, $bound);
}
# Parameter type check
for each param:
die unless $ptype->contains($args[$i]);
# Call original
my @result = $original->(@args); # Always list context
# Return type check
die unless $rtype->contains($result[0]);
return wantarray ? @result : $result[0];
}
Effect Handler Stack (Typist::Handler)¶
@HANDLER_STACK: LIFO stack of { effect => name, handlers => { op => sub } }
push_handler(effect, handlers):
push onto stack
find_handler(effect):
reverse search stack for matching effect name
Effect::op(@args):
find_handler(effect) -> call handlers->{op}->(@args)
die if no handler found
handle { BODY } Effect => +{ ... }:
push handlers
eval { BODY }
pop handlers (even on exception)
if exception and Exn handler with throw:
return Exn_handler->{throw}->($err)
re-raise if exception (no Exn handler)
Cost Summary¶
Static-only Runtime mode
─────────── ────────────
Per scalar read 0 1 method dispatch + hash deref
Per scalar write 0 1 method dispatch + contains()
Per function call 0 N * contains() + 1 * contains()
Per generic call 0 N * infer_value + instantiate +
N * parse(bound) + N * is_subtype
Newtype construct 0 contains()
Datatype construct arity only arity + N * contains()
Struct construct fields only fields + N * contains()
Effect::op/handle stack ops stack ops (always active)
Module Loading Strategy¶
Eager vs. Lazy Loading¶
EAGER (loaded with `use Typist`):
Type::* (including Data, Fold), Parser, Registry, Handler, etc.
These are needed for compile-time attribute processing and
constructor generation.
DEFERRED (loaded on first use):
Attribute deps — Parser, Inference, Subtype, Tie::Scalar, Transform,
Kind, KindChecker (via _ensure_deps() on first :sig())
Inference — via require in import() when -runtime is set
Subtype — via require in import() when -runtime is set
LAZY (loaded only in CHECK phase):
PPI — via require Static::Checker in CHECK phase
Static::* — via require in _check_analyze()
Prelude — via use in Analyzer/Workspace
NEVER (unless LSP):
LSP::* — only loaded by bin/typist-lsp
JSON::PP — only needed for LSP transport
Rationale¶
PPI is the heaviest dependency (~1MB of code, creates full ASTs). By loading it lazily via opt-in static analysis paths, programs that just use Typist; avoid the PPI startup cost entirely. PPI and all Static::* modules are never loaded during normal program execution unless -check, TYPIST_CHECK=1, typist-check, or the LSP explicitly needs them.
The Attribute module defers its 8 heavy dependencies (Parser, Inference, Subtype, etc.) via _ensure_deps(), loading them only on the first :sig() annotation encountered. When no :sig() is used (e.g., a module that only defines types via struct/datatype), these modules are never loaded.
The eagerly-loaded modules are lightweight: they define type node classes (small hashref-based objects) and the registry (hash-based storage). This is the minimum needed to generate constructors at compile time.
Design Principles¶
- Static-first: errors caught before runtime; runtime enforcement is opt-in
- Immutable types: type nodes are value objects;
substitutereturns new nodes - Flyweight atoms: singleton semantics via
%POOLfor primitive types - Normalized constructors: Union/Intersection flatten and deduplicate
- Lazy heavy deps: PPI loaded only in CHECK phase, never at runtime
- Dual-mode Registry: class methods for singleton (CHECK), instance methods for LSP
- Gradual typing: annotation density determines check strictness;
Anybypasses checks - Zero runtime cost: constructor type validation is opt-in (
-runtime); structural checks (arity, unknown fields) are always active - No source filters: standard Perl attributes + PPI parsing for static analysis
- Effect handlers:
Effect::op(...)/handleprovide dynamic-scope effect dispatch at runtime