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:

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:

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:

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.