Type Checking with PSC
PSC is a static type checker for Perl. If that sentence surprises you, read on — this section explains why it shouldn't.
Perl already has a type system
Every time you write my $x, you declare a Scalar. Every time you write my @xs, you declare an Array. Every time you write my %h, you declare a Hash. Sigils are type declarations. Perl has had them since the beginning.
Below the surface there is more structure than most people realize. A Scalar is not just "a scalar" — it can hold an Int, a Num, a Str, Undef, a Bool, or a Ref. Perl tracks these distinctions internally. The runtime knows when $x holds an integer versus a string. It just never tells you when you use the wrong one.
That is the gap PSC fills. Perl has a real type system — a lattice with proper subtyping relationships: Int is a subtype of Num, Num is a subtype of Str, Str is a subtype of Scalar. These are not inventions bolted on after the fact. They fall out of how Perl values actually behave: an Int can do everything a Num can do, a Num can do everything a Str can do. PSC formalizes what was always there and checks it.
Consider what happens when you use a string where a number is expected:
my $x = "hello";
my $y = $x + 1; # Perl: no error, $y is 1 ("hello" coerces to 0)
# PSC: error — argument expects Num, got Str [type-mismatch]
Perl does not complain. It coerces "hello" to 0 and moves on. You get 1 and a silent bug. PSC catches the mismatch before the code runs. The type information was always in the program — in the sigils, in the operations, in the structure. PSC reads what Perl already knows and holds you to it.
For the formally curious, the type system is documented in two companion papers: a practical guide and a formal definition of Perl's latent dynamic type system, covering the subtyping lattice, type membership via round-trip coercion, and behavioral contracts.
No annotations, no opt-in
PSC works on your existing Perl code as-is. You do not add type annotations. You do not opt in. You do not change how you write Perl. The types come from what Perl already knows: sigils declare container types, operations constrain value types, and control flow narrows both. PSC infers the rest.
Run it against a file. If there are no type errors, you get silence and a zero exit code. If there are errors, you get a diagnostic that names the file, line, column, and the specific mismatch. That is the entire interface.
Note: PSC is under active development. The type checker grows more capable with each release — the set of diagnostics it produces today will expand as inference covers more of Perl's semantics. What it catches, it catches correctly. What it does not yet catch is on the roadmap.
Basic usage
Run psc check against a file or an entire directory:
psc check lib/MyModule.pm
psc check lib/ # Check entire directory
Diagnostics are written to stdout in a format compatible with most editors and CI systems. A zero exit code means no errors were found.
What it catches
Arity mismatches
PSC knows the expected argument counts for Perl's built-in functions. Calls that pass the wrong number of arguments are flagged immediately:
push(); # error: expected at least 2 arguments, got 0
chr("hello", 2); # error: expected 1 argument, got 2
These errors carry the diagnostic code arity-mismatch.
Type mismatches
When a variable's inferred type does not match what a function expects, PSC reports the conflict:
my $x;
push($x, 1); # error: argument 1 expects Array, got Scalar
The first argument to push must be an Array. Because $x is declared with a scalar sigil, its inferred type is Scalar, and the mismatch is caught statically. These errors carry the diagnostic code type-mismatch.
How inference works
PSC uses a two-pass architecture over the syntax tree.
Pass 1: Declaration collection
The first pass walks the tree top-down, collecting every variable declaration it encounters. The type of each variable is initialized from its sigil: $ variables start as Scalar, @ variables start as Array, and % variables start as Hash. This gives PSC a complete picture of what names are in scope before any expression analysis begins.
Pass 2: Bottom-up inference
The second pass walks the tree bottom-up, inferring types for expressions and validating function call sites against their expected signatures. Flow narrowing is applied within guard blocks during this pass.
The type system
Types are represented internally as bitsets (uint32), which makes union and intersection operations fast. The type hierarchy covers the full range of Perl values: Undef, Bool, Int, Num, Str, Array, Hash, Ref — and for Ref, the subtypes ScalarRef, ArrayRef, HashRef, CodeRef, and Object. A variable's inferred type at any point is the union of all types it could hold at that point in the program.
Guard suggestions
When PSC detects a type mismatch, it looks at the inferred type and checks whether a guard pattern would eliminate the problematic cases. If one would, it includes a hint in the diagnostic output:
lib/app.pl:5:3: error: call to chr: argument 1 expects Int, got Scalar [type-mismatch]
hint: Add guard: if (defined($x)) { ... }
The hint is telling you that wrapping the call in a defined check would narrow $x's type enough to satisfy the constraint. PSC supports three guard patterns:
defined($x)— removes Undef from the variable's type inside the blockref($x)— narrows to reference types inside the blockbuiltin::is_bool($x)— narrows to Bool inside the block
Suggestions are hints, not requirements. PSC surfaces them when they represent the most direct path to making the code statically valid. Whether to act on them is your decision.
Flow narrowing
PSC tracks how guard expressions affect variable types inside their blocks. A variable's type at any given statement is the type it has after all enclosing guards have been applied:
my $x; # Scalar (broad union, includes Undef)
if (defined($x)) {
chr($x); # OK — $x is narrowed to Scalar without Undef
}
Outside the if block, $x retains its original broad type. Narrowing is scoped to the guard block. This means PSC can validate code that handles missing values correctly without treating every Scalar as an error.
Cross-file analysis
PSC resolves use statements and follows them into the modules they load. Return types inferred from one file are available when checking code that calls into it:
use MyModule;
my $result = MyModule::process($data); # return type inferred from MyModule
This means that checking a directory with psc check lib/ gives you whole-program analysis, not just per-file analysis. Errors at call sites in one file can be traced back to type decisions made in another.
Diagnostic output format
Every diagnostic follows this structure:
filename:line:col: severity: message [code]
The diagnostic codes are:
arity-mismatch— wrong number of arguments to a built-in functiontype-mismatch— argument type does not match the expected typeunknown-builtin— PSC does not recognize the function being called
The code field makes it straightforward to filter or suppress specific categories of diagnostic in a CI pipeline or editor integration.
LSP server
PSC ships with a built-in LSP server, which means it can deliver diagnostics directly inside your editor without any separate language server installation. Start it with:
psc lsp
The LSP server supports:
- Hover tooltips — shows the inferred type of the symbol under the cursor
- Inline diagnostics — type mismatches and arity errors appear as you type
- Go-to-definition — jumps to the declaration of a variable or subroutine
- Document symbols — lists all symbols defined in the current file
Guard suggestions appear as diagnostic hints alongside the inline errors, so you can see them without leaving your editor. See the Editor Setup guide for copy-paste configurations for Vim, Neovim, VS Code, Emacs, Sublime Text, and Helix.
Cookbook
For concrete examples of every check PSC performs — with the Perl code, the diagnostic output, and the fix — see the Type Checking Cookbook.