Multi-File Projects¶
Real projects span multiple files and packages. This page covers how to organize type definitions across modules, how cross-file type resolution works, and how to structure a project for clean type-level dependencies.
Shared Type Definitions Module¶
The recommended pattern is a dedicated types module that defines all shared domain types and exports their constructors:
# lib/MyApp/Types.pm
package MyApp::Types;
use v5.40;
use Typist;
use Exporter 'import';
our @EXPORT = qw(UserId Email Product Order);
BEGIN {
newtype UserId => 'Int';
newtype Email => 'Str';
struct Product => (
id => 'Int',
name => 'Str',
price => 'Int',
);
struct Order => (
id => 'Int',
product_id => 'Int',
quantity => 'Int',
);
}
1;
Key points:
BEGINblocks ensure types are registered before compile-time analysis and cross-file tooling runs.@EXPORTexports constructor functions (UserId(...),Product(...)) into the caller's namespace via Perl's standard Exporter mechanism.- Type names in
:sig()annotations are resolved from the Registry, not from@EXPORT. But constructors used as function calls in code do need to be exported.
Consumer Modules¶
Modules that use these types import the types module:
# lib/MyApp/Service.pm
package MyApp::Service;
use v5.40;
use Typist;
use MyApp::Types;
sub find_product :sig((Int) -> Product) ($id) {
Product(id => $id, name => "Widget", price => 100);
}
sub create_order :sig((Int, Int) -> Order ![IO]) ($product_id, $qty) {
say "Creating order for product $product_id...";
Order(id => 1, product_id => $product_id, quantity => $qty);
}
1;
When MyApp::Service says use MyApp::Types:
- Perl runs
MyApp::Types::import(), which exports constructor subs (UserId,Product, etc.) into the caller's namespace. - The
BEGINblocks inMyApp::Typeshave already executed, registering all types in the global Registry. - Type names like
Productin:sig((Int) -> Product)are resolved by the static analyzer from the Registry.
How Cross-File Type Resolution Works¶
The Two Worlds¶
Typist operates in two distinct namespaces simultaneously:
| World | What it resolves | Mechanism |
|---|---|---|
| Perl | Constructor calls, subroutine calls, exported symbols | Standard use/@EXPORT/Exporter |
| Typist | Type names inside :sig(), field types in struct, etc. |
Parser + Registry (string-based, global) |
Both are activated by use, but they work differently:
- Perl imports require
@EXPORTand follow Perl's standard namespace rules. Without export,Product(...)as a function call will fail. - Typist type names are available globally once registered via
BEGINblocks. The string'Product'in:sig((Int) -> Product)resolves via the Registry regardless of what is in@EXPORT.
Synthetic Namespaces¶
Several Typist constructs create packages that have no corresponding .pm file:
| Construct | Created namespace | Example calls |
|---|---|---|
effect Logger => +{...} |
Logger:: |
Logger::log(...) |
typeclass Show => ... |
Show:: |
Show::show(...) |
newtype UserId => ... |
UserId:: |
UserId::coerce(...) |
struct Person => (...) |
Person:: |
Person::derive(...), $p->name() |
These are created as side effects of executing BEGIN blocks. Any code loaded after the defining module can use these qualified calls directly -- no export needed.
What Needs Exporting¶
| Item | Needs @EXPORT? |
Why |
|---|---|---|
Constructor functions (Product(...)) |
Yes | Called as bare functions in Perl code |
Type names in :sig() |
No | Resolved from Registry strings |
Qualified calls (Logger::log(...)) |
No | Perl resolves Package::sub directly |
Struct accessors ($p->name) |
No | Method dispatch on blessed objects |
The ImportHint Diagnostic¶
The static analyzer tracks which package defined each type (Registry.set_defined_in) and which packages the current file imports (from use statements). When a type name used in :sig() was defined in a package that is not reachable through the current file's use chain, an ImportHint diagnostic is emitted.
This is a hint (severity 4), not a hard error. The type still resolves via the global Registry. The diagnostic helps maintain explicit import discipline.
To resolve: add use MyApp::Types; to the consuming module.
Project Layout¶
A typical multi-file Typist project:
lib/
MyApp/
Types.pm # Shared newtypes, structs, ADTs, effects
Types/
Domain.pm # Domain-specific types (optional split)
Events.pm # Event types
Service.pm # Business logic with :sig() annotations
Repository.pm # Data access layer
Handler.pm # Effect handlers / infrastructure
Type Module Organization¶
For small projects, a single Types.pm is sufficient. As the project grows, split by concern:
# lib/MyApp/Types/Domain.pm
package MyApp::Types::Domain;
use v5.40;
use Typist;
use Exporter 'import';
our @EXPORT = qw(UserId ProductId Price Quantity Product Order OrderItem);
BEGIN {
newtype UserId => 'Int';
newtype ProductId => 'Str';
newtype Price => 'Int';
newtype Quantity => 'Int';
struct Product => (
id => 'ProductId',
name => 'Str',
price => 'Price',
);
struct OrderItem => (
product => 'ProductId',
quantity => 'Quantity',
price => 'Price',
);
struct Order => (
id => 'Int',
items => 'ArrayRef[OrderItem]',
);
}
1;
# lib/MyApp/Types/Events.pm
package MyApp::Types::Events;
use v5.40;
use Typist;
use Exporter 'import';
our @EXPORT = qw(OrderCreated OrderCancelled);
# Import domain types so we can reference them
use MyApp::Types::Domain;
BEGIN {
datatype OrderEvent => (
OrderCreated => '(Order)',
OrderCancelled => '(Int, Str)', # order_id, reason
);
}
1;
Re-Exporting¶
A top-level module can re-export everything:
# lib/MyApp/Types.pm
package MyApp::Types;
use v5.40;
use Exporter 'import';
use MyApp::Types::Domain;
use MyApp::Types::Events;
our @EXPORT = (
@MyApp::Types::Domain::EXPORT,
@MyApp::Types::Events::EXPORT,
);
1;
Cross-File Effects and Typeclasses¶
Effects and typeclasses follow the same pattern as types:
# lib/MyApp/Effects.pm
package MyApp::Effects;
use v5.40;
use Typist;
BEGIN {
effect Logger => +{
log => '(Str) -> Void',
};
effect DB => +{
query => '(Str) -> Str',
execute => '(Str) -> Int',
};
}
1;
Consumer modules use MyApp::Effects and then use Logger::log(...) and ![Logger] in :sig() annotations. No export is needed for effect operations -- they are installed as qualified subs in synthetic namespaces.
Cross-File Typeclass Instances¶
Typeclass instances can be defined in any module:
# lib/MyApp/Instances.pm
package MyApp::Instances;
use v5.40;
use Typist;
use MyApp::Types::Domain;
BEGIN {
instance Show => 'Product', +{
show => sub ($p) { "Product(" . $p->name . ")" },
};
}
1;
Known limitation: the static analyzer registers instance existence but does not verify method completeness across files. Instance completeness is checked at runtime. This is because cross-file loading order is non-deterministic during static analysis.
LSP Workspace Support¶
The typist-lsp server provides cross-file support via the Workspace module. When you open a project, the LSP:
- Scans all
.pmfiles under the workspace root. - Extracts type definitions, function signatures, effects, typeclasses, and instances from each file.
- Builds a shared Registry for cross-file resolution.
- Updates incrementally when files change (
didSave).
This gives you:
- Cross-file hover: hover over a type name to see its definition, even if it is defined in another file.
- Cross-file go-to-definition: jump to the source of a type, function, or effect.
- Cross-file diagnostics: type mismatches that involve types from other files are caught.
- Cross-file completion: struct fields, effect operations, and constructors from other files appear in completion lists.
The typist-check CLI uses the same Workspace for cross-file resolution.