Conventions¶
Canonical reference for Typist coding conventions, design patterns, and known Perl gotchas. For architecture overview, see architecture.md. For type system reference, see the Guide. For static analysis internals, see static-analysis.md.
Table of Contents¶
- Language and Module Conventions
- Type System Conventions
- Static-First Design
- Syntax Conventions
- Feature Reference
- Namespace Model
- Design Principles
- Perl Gotchas
- Module Decomposition Patterns
- Cross-References
Language and Module Conventions¶
- All modules use
use v5.40and subroutine signatures (($self, $arg, ...)). - No source filters or external preprocessors. The system relies entirely on standard Perl attributes and PPI parsing for static analysis.
- Sub wrapping uses direct glob assignment to replace the original subroutine.
Type System Conventions¶
Immutable Type Nodes¶
Type nodes are immutable value objects. The substitute method returns a new node rather than mutating in place.
Flyweight Atoms¶
Atom types use a flyweight pool (%POOL) for singleton semantics. Every reference to Int within a process points to the same object.
Normalized Constructors¶
Union and Intersection constructors normalize their members by flattening nested unions/intersections and deduplicating members.
Two-Tier Composite Types¶
- Record -- structural, plain hashrefs via
typedef Name => Record(...). - struct -- nominal, blessed immutable objects via
struct Name => (fields...). - Constructors:
Name(field => val) - Accessors:
$obj->field - Immutable derive:
Name::derive($obj, field => val) optional(field => Type)marks fields that can be omitted (returns("field?", Type)pair, flattened into field list).- Subtyping:
Struct <: Record(structural compatibility), butRecord </: Struct(nominal barrier).
Two-Tier Collection Types¶
- Array[T] / Hash[K,V] -- list types. These are what
grep,map,sort, and@derefproduce (Perl's list context). - ArrayRef[T] / HashRef[K,V] -- scalar reference types. These are what
[LIST]and+{LIST}produce. [Array[T]]flattens toArrayRef[T].- Array and Hash are NOT subtypes of ArrayRef/HashRef -- they are fundamentally different (list vs reference).
TypeClass Dispatch Namespace¶
TypeClass dispatch installs into the caller's namespace (${caller}::${ClassName}::${method}), not into Typist::TC::*.
Static-First Design¶
Default Mode¶
use Typist; installs runtime helpers and the prelude only. No static analysis runs by default, and original subs execute directly with no tie and no wrappers.
Runtime Mode¶
use Typist -runtime; (or TYPIST_RUNTIME=1) additionally enables Tie::Scalar variable monitoring on :sig() annotated variables.
Structural Enforcement (Always Active)¶
Constructors always perform cheap structural checks, regardless of -runtime:
- newtype: creates blessed scalar reference.
Name::coerce($val)extracts the inner value. - datatype: argument count must match variant definition.
- struct: unknown field and missing required field checks.
- Effect dispatch:
Effect::op(@args)dispatches to the nearest handler on the runtime stack.
Type validation (contains, infer_value, bounds, typeclass constraints) in constructors requires -runtime.
CHECK Phase¶
The CHECK phase runs both structural checks (Checker) and full static analysis (Analyzer with TypeChecker + EffectChecker) per loaded package. Diagnostics surface as warn to STDERR, which perlnavigator picks up.
If you opt into CHECK analysis, suppress duplicate terminal diagnostics with TYPIST_CHECK_QUIET=1 when using typist-lsp.
Gradual Typing¶
Annotation density determines check strictness:
- Fully annotated -- all checks enforced.
- Partially annotated (some
:sig(), no:Eff) -- pure effect assumed, return type unknown if no:Returns. - Completely unannotated --
(Any...) -> Any, type checks skip, effect treated as pure (no constraint).
Mechanism | use Typist (default) | use Typist -runtime
----------------------|----------------------|---------------------
Static Analysis | ON | ON
CHECK diagnostics | ON | ON
Structural checks | ON | ON
Effect dispatch | ON | ON
Typeclass dispatch | ON | ON
Constructor type val. | OFF | ON
Tie::Scalar | OFF | ON
Syntax Conventions¶
Hashref Literals¶
Always use +{} to disambiguate from blocks:
String Syntax for Signatures¶
Typeclass and effect definitions use string syntax for method/operation signatures, consistent with :sig() annotations:
The Extractor only captures PPI::Token::Quote, so programmatic type constructors do not work for static analysis.
String-Based Type Declarations¶
use Typist enables the type system and exports all core functions (typedef, newtype, struct, optional, etc.). All type declarations use strings:
use Typist;
typedef Name => 'Str';
struct Person => (name => 'Str', age => 'Int', optional(email => 'Str'));
instance Show => 'Int', +{ show => sub ($x) { "$x" } };
No separate imports are needed for type names — they are resolved from strings by the Parser and Registry.
Unified :sig() Annotation¶
- Generics in
<>. - Arrow
->separates parameters from return type. ![...]introduces the effect row (brackets are required).
LSP Coverage Rule¶
When adding or modifying static analysis features, update docs/lsp-coverage.md. New analysis outputs must have corresponding LSP entries (or an explicit "N/A" with rationale).
Feature Reference¶
Algebraic Data Types (ADT)¶
Constructors are installed into the caller's namespace. Parameterized ADTs:
Type params are promoted from aliases to Var objects. Subtyping is covariant in type arguments.
GADT¶
Constructors with -> specify per-constructor return types. Provides is_gadt predicate and constructor_return_type($tag) accessor.
Nullary Datatype (Enumeration)¶
All-nullary constructors model pure enumerations.
Pattern Matching¶
Dispatches on _tag, splats _values into handlers. _ is the optional fallback arm. Emits exhaustiveness warnings for registered ADTs when arms are incomplete and no fallback is given.
Effects¶
Operations are auto-installed as qualified subs (Console::writeLine(@args)), dispatching to the nearest handler on the runtime stack.
Bounded Effect Generics¶
Type parameters on effects support the same bound syntax as functions and structs: type bounds (S: Num), typeclass constraints (T: Show), or compound (T: Num + Ord). The Checker validates type arguments against bounds at the annotation site — ![Counter[Str]] produces a TypeMismatch because Str is not a subtype of Num.
Effect Protocols¶
effect DB => qw/Connected Authed/ => +{
connect => protocol('(Str) -> Void', '* -> Connected'),
query => protocol('(Str) -> Str', 'Authed -> Authed'),
disconnect => protocol('() -> Void', 'Authed -> *'),
};
* is the ground state (protocol inactive). Only active states appear in the states list. A function that begins a protocol session transitions from *:
Mid-protocol functions may use any valid active state as From:
Annotation: ![DB<* -> Authed>] declares start/end states. ![DB<Authed>] is invariant. ![DB] defaults to * -> * (full session cycle). ProtocolChecker traces operation sequences and verifies state transitions.
Effect Handlers¶
Installs scoped effect handlers, executes BODY, and guarantees cleanup even on exception. The Exn effect bridges Perl's die to the handler system:
Variadic Functions¶
Rest parameter with ...Type syntax. Arity checking uses minimum args. Default parameters ($x = expr) reduce minimum arity via default_count.
Type Narrowing¶
defined($x)narrowsMaybe[T]toTin the then-block.if ($x)(truthiness) narrows by removingUndef.$x isa Foonarrows toFoo.ref($x) eq 'TYPE'/ref($x) ne 'TYPE'narrows to the corresponding type (with or without parens onref).return unless defined($x)narrows for the rest of the body (early return).- Else-blocks receive inverse narrowing.
Literal Widening¶
Unannotated my $var = LITERAL widens Literal(v, B) to Atom(B):
my $total = 0infers asIntmy $rate = 3.14infers asDoublemy $name = "hi"infers asStrBoolbase widens toInt(0/1 are numbers in Perl)
Expression-level inference is unchanged: Infer->infer_expr still returns Literal(0, 'Bool').
Variable Reassignment¶
:sig annotated variables are checked on reassignment ($x = expr). Unannotated variables are not checked.
Method Calls¶
All of the following are type-checked:
$self->method()-- same-package instance method.$p->name()-- cross-package struct accessor.Person->new()-- class method.Name::derive($p, ...)->greet()-- chained calls via return type resolution.- Generic methods -- delegated to
_check_generic_call. - Record accessor calls.
Union receivers and untyped receivers are gradual-skipped.
Return Type Inference¶
handle { BLOCK }infers from the block's last expression.matchcollects arm return types and computes union/LUB.- Both bypass the
Word + Listcall pattern used for normal function inference.
Cross-File Typeclass Instances¶
instance declarations are extracted, registered (existence only), and tracked per-file by Workspace. Static registration does not validate method completeness (cross-file ordering is non-deterministic); completeness checking is deferred to runtime.
Namespace Model¶
Typist operates in two distinct worlds. Understanding where each name lives is essential for working with the system.
The Two Worlds¶
| World | Content | Resolution |
|---|---|---|
| Perl | Subroutine calls, use/import, @EXPORT |
Perl's standard namespace rules |
| Typist | Type expressions inside :sig(), typedef, struct field types |
Typist's Parser + Registry (string-based, global) |
A type name like Int exists in both worlds:
- In
:sig(Int)— the string token"Int"is resolved by the Parser against the Registry. No import needed. - In
typedef Name => 'Int'— the string'Int'is coerced into a type object viaTypist::Type->coerce. No import needed.
Synthetic Namespaces¶
Several Typist keywords create namespaces that have no corresponding .pm file. These follow a uniform pattern: ${TypeName}::${operation}.
| Keyword | Created namespace | Operations | Perl callable? |
|---|---|---|---|
effect Logger => +{...} |
Logger:: |
Logger::log(...) |
Yes — runtime effect dispatch |
typeclass Show => ... |
Show:: |
Show::show(...) |
Yes — runtime instance dispatch |
newtype UserId => ... |
UserId:: |
UserId::coerce(...) |
Yes — unwrap inner value |
struct Person => (...) |
Person:: |
Person::derive(...), Person::name(...) |
Yes — derive + accessors |
datatype Option => (...) |
(none) | — | Constructors (Some, None) go into the defining package |
These are available after the defining code has executed (typically in a BEGIN block). If Shop::Types defines effect Logger => ..., then any code loaded after use Shop::Types can call Logger::log(...).
What use Controls¶
| Statement | What it does |
|---|---|
use Typist |
Enables the type system for this package (attribute handlers, CHECK registration). |
use Typist -runtime |
Additionally enables Tie::Scalar monitoring for :sig() variables. |
use Shop::Types |
(1) Imports constructors via Exporter. (2) Side-effect: registers types in the global Registry, making them available in :sig(). (3) Side-effect: creates synthetic namespaces for effects/typeclasses. |
Dependency Tracking¶
When reading code, there are two kinds of dependencies to trace:
- Visible (via
use): constructor functions, Exporter@EXPORTitems. - Implicit (via Registry side-effects): type names in
:sig(), synthetic namespace operations.
Both are activated by use, but only the first kind appears in @EXPORT. The second kind is a side-effect of executing BEGIN blocks that call typedef, newtype, effect, etc.
Visibility Check (ImportHint)¶
The static analyzer tracks type provenance (which package defined each type) and use chains (which packages the current file imports). When a type name used in :sig() was defined in a package that is not reachable through the current file's use declarations, an ImportHint diagnostic (severity: hint) is emitted.
This is an advisory check, not a hard error — types still resolve via the global Registry regardless of visibility. The diagnostic helps developers maintain explicit import discipline.
# ✗ ImportHint: Type 'Amount' (defined in Shop::Types) used but 'Shop::Types' is not imported
package Order;
use v5.40;
sub total :sig((Amount) -> Amount) ($a) { ... }
# ✓ No hint — Shop::Types is explicitly imported
package Order;
use v5.40;
use Shop::Types;
sub total :sig((Amount) -> Amount) ($a) { ... }
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.
Perl Gotchas¶
die as a List Operator¶
die is a list operator in Perl, so $x // die "msg", k => v parses as $x // die("msg", k => v). The k => v becomes part of the die argument list.
Fix: always parenthesize die when used with // or other low-precedence operators:
PPI find() Returns Empty String¶
$doc->find('PPI::Token::Word') returns '' (empty string) when no results are found, not undef.
Fix: always normalize the result:
PPI Quote Content vs String¶
$token->content returns the token including its quote delimiters (e.g., 'hello'). To get the inner value, use $token->string (e.g., hello).
Hashref Disambiguation¶
A bare {} is ambiguous between a block and an anonymous hashref. Always prefix with + to force hashref interpretation:
PPI Anonymous Sub Signatures¶
PPI parses anonymous sub signatures as PPI::Token::Prototype, not PPI::Structure::List. Account for this when traversing anonymous sub parameters.
reverse Precedence¶
reverse EXPR .. EXPR binds incorrectly without parentheses.
Fix: always parenthesize:
(&@) Prototype Comma Trap¶
Functions with (&@) prototype (like handle, and Perl builtins map, grep) expect a block followed by a list with no comma separating them. Inserting a comma after the block silently breaks the call:
# Correct: no comma after block
handle { BLOCK } Logger => +{ ... };
# WRONG: comma after block silently breaks -- list part becomes void-context
handle { BLOCK }, Logger => +{ ... };
The same rule applies to map, grep, sort, and any other (&@) prototyped function.
Operator Overload Signature Caveat¶
Operator overload subs (use overload) cannot use Perl subroutine signatures. Use the traditional @_ unpacking:
Module Decomposition Patterns¶
大きなモジュールは責務ごとにサブモジュールへ分解する。以下の2パターンを使い分ける。
Partial-package パターン¶
同一パッケージ名を複数ファイルに分割する。親ファイルが require でパーシャルをロードし、呼び出し側からは単一モジュールに見える。
- 用途: 単一の責務が大きくなったモジュール(例:
LSP::Document) - パーシャルは親のサブディレクトリに配置
- パーシャルは
package宣言で親と同じパッケージ名を使う
サブモジュール分解¶
内部実装を独立したパッケージに切り出す。親モジュールが公開 API を統括し、サブモジュールは内部詳細として扱う。
- 用途: 推論や環境構築など、内部ロジックの分離(例:
Static::Infer::*,Static::TypeEnv::*) - サブモジュールは親のサブディレクトリに配置
- 公開 API は親モジュール経由
Cross-References¶
- architecture.md -- Module dependency graph, lifecycle, registry design, error system.
- Guide -- All type constructs, subtyping rules, advanced features.
- static-analysis.md -- Analysis pipeline internals, TypeChecker, EffectChecker, inference.
- lsp-coverage.md -- LSP feature coverage matrix.