Modules and Namespaces
Osprey multi-file programs are built from logical namespaces and explicit modules, not from file paths. A source file's path decides whether it belongs to a project; it does not decide the names it exports.
Flavor layer - shared core (AST and above). Namespace/import resolution, module signatures, exports, state ownership, separate compilation, and project assembly are shared-core semantics. The Default flavor and ML flavor may spell declarations differently, but both lower to the same canonical project model:
NamespaceDecl,ModuleDecl,SignatureDecl,Import, and symbol paths. No type checker, effect checker, code generator, runtime, or LSP feature may infer semantics from.ospvs.ospmlonce lowering has happened. See Language Flavors.
Status
import and module syntax are parsed today, and module bodies are checked in a
child scope. Cross-file resolution, open namespaces, explicit exports,
signatures, module-owned state rules, and project assembly are planned. This
chapter is the normative contract for those features and supersedes the
fiber-isolated module sketch in Fibers and Concurrency.
Research Basis
[MODULES-RESEARCH] The design combines .NET-style logical named groups with
ML-style abstraction boundaries and Osprey's algebraic effects. It deliberately
does not adopt the usual .NET Company.Product.Feature hierarchy as an
Osprey norm.
- Parnas set the bar for modularity: "The effectiveness of a "modularization" is dependent upon the criteria used" (Parnas 1972).
- The .NET precedent Osprey keeps is the named logical group: "A namespace declaration assigns your types to a named group" (Microsoft namespace guide).
- The .NET Framework Design Guidelines document the familiar hierarchy template
<Company>.(<Product>|<Technology>)[.<Feature>]; Osprey records that as precedent, not a recommendation for app code (Microsoft namespace guidelines). - F# separates namespaces from modules: a namespace attaches a name to related program elements, while a module groups F# constructs such as types, values, and functions (F# namespaces, F# modules).
- Slash-style module names have precedent: Racket says a string module path uses
Unix-style
/as the separator (Racket module paths), and Go import paths are string literals such as"lib/math"(Go spec). - Rust gives the item-qualification precedent Osprey follows: "A path is a
sequence of one or more path segments separated by
::tokens" (Rust Reference). - OCaml's module system makes signatures the abstraction boundary: "A signature specifies which components of a structure are accessible" (OCaml manual).
- Haskell modules are explicit about import/export control; the Report defines modules with import declarations and optional export lists (Haskell 2010 Report).
- Elm keeps module exposure visible at the top of the file through
exposinglists (Elm modules guide). - Clojure's namespace guide makes aliasing first-class because long names are rarely what readers want at every call site (Clojure namespaces).
- Java's reverse-domain convention is about globally unique published packages; the JLS says it piggybacks on an existing unique-name registry, not source location (JLS unique package names).
- Harper and Lillibridge identify the core problem as "the management of the flow of information between program units" (POPL 1994).
- Rossberg, Russo, and Dreyer summarize the ML lesson: "ML modules are a powerful language mechanism for decomposing programs" (F-ing Modules).
- Leroy's manifest-types work requires a "strict distinction between abstract types and manifest types" (POPL 1994).
- Backpack states the separate-compilation target: "explicit interfaces express assumptions about dependencies" (Kilpatrick, Dreyer, Peyton Jones, Marlow 2014).
- Launchbury and Peyton Jones justify encapsulated mutable state: "Some algorithms make critical internal use of updatable state" (Lazy Functional State Threads).
- Plotkin and Pretnar make state a handled effect: effects include "state, time, and their combinations" (Handlers of Algebraic Effects).
- Moseley and Marks give the architectural rule: "Separate" essential state from essential logic and accidental state/control (Out of the Tar Pit).
- Linear Haskell points at the resource-state horizon: "typestates ... are actually enforced by the type system" (Bernardy et al. 2018).
- Modern lexical effect handlers aim at "local-reasoning principles" (Ma, Ge, Lee, Zhang 2024).
- Redux captures the state-management operational rule: "single source of truth" (Redux Three Principles).
These are not ornamental citations. They drive the rules below: names are logical, interfaces are explicit, abstract state does not leak, and mutable state has one owner.
Comparative Practice
[MODULES-COMPARATIVE-PRACTICE] The survey above yields concrete rules:
- Use namespaces for logical grouping, not architecture. .NET/F# names are a useful precedent for path-independent grouping, but Osprey does not copy the deep enterprise naming convention as the default shape.
- Use modules for boundaries. OCaml/F#/ML practice puts abstraction, signatures, and implementation hiding at the module boundary; Osprey follows that instead of making namespaces carry privacy or state.
- Make import surface area visible. Haskell, Elm, and Clojure all make import/export choices visible in source. Osprey therefore supports explicit member imports and aliases, and treats wildcard imports as a script/test convenience.
- Separate module paths from member access. Rust's
::keeps item paths visually distinct from record field access; Osprey uses::for namespace, module, and exported-member paths, leaving.for value/member operations. - Allow slash names only as labels. Racket and Go show precedent for slash-like module/import paths, but in Osprey a quoted slash namespace is one opaque label. It does not imply folder mirroring, parent namespaces, or load order.
- Reserve reverse-DNS/deep names for distribution. Java's reverse-domain convention solves global package collision, not local application design. Osprey may use similar labels for published libraries later, but app code should usually stay flat.
Design Goals
[MODULES-GOALS] The module system must make the good structure the easy
structure:
- Path-independent names. A namespace label comes from source text, not from
src/foo/bar.osp. - Flat-first namespaces. A good namespace is usually one short project or domain label, not a forced company/product/feature tower.
- Separators are spelling, not architecture. A quoted namespace may contain
/when a project wants folder-like names, but/does not create parent namespaces, inheritance, visibility, or initialization order. - Open namespaces, closed modules. Namespaces organize; modules encapsulate.
- Explicit imports and exports. Wildcard visibility is the escape hatch, not the default.
- Separate compilation by interface. A file can be checked against imported signatures without loading every implementation detail.
- State has a declared owner. Top-level mutable state is forbidden outside a state module or handler-owned state region.
- Pure logic stays pure. Modules expose state through effect-typed operations or pure query/update functions, not exported cells.
- Cross-flavor interop. A
.ospmodule and.ospmlmodule import each other through canonical signatures.
Canonical Project Model
[MODULES-MODEL] The module system is a project graph. Concrete syntax is only
how each flavor contributes nodes and edges to that graph.
The shared model contains:
SourceFile { path, flavor, namespace }- a parsed file with one active flavor and one namespace label, explicit or project-defaulted.Namespace { label, contributions }- an open logical grouping of declarations from any number of files.Module { namespace, path, kind, exports, private_items, signature }- a closed boundary inside a namespace.kindisplainorstate.Signature { name, items }- an interface contract for a module.ImportEdge { from_file, target, alias, imported_members }- a dependency on a namespace/module/member surface, never on a physical file.SymbolId { namespace, path }- the stable identity for exported declarations.StateOwner { module, cells, access_paths }- the single owner of private durable state in astate module.
Every later phase consumes this model, not surface syntax:
source files (.osp/.ospml)
-> flavor parsers
-> canonical project graph
-> import resolution
-> signature and privacy checking
-> type/effect checking
-> codegen/runtime/LSP/docs
No semantic rule below depends on braces, layout, fn, whitespace application,
or named arguments. Those are flavor concerns described in
Syntax, Language Flavors, and
ML Flavor Syntax.
Surface Projection
[MODULES-FLAVOR-PROJECTION] Each flavor projects the same model into its own
surface. The examples in this chapter are illustrative; the model above is the
normative layer.
| Concept | Shared model | Default flavor | ML flavor |
|---|---|---|---|
| Namespace contribution | Namespace { label } |
namespace billing { ... } or namespace billing; |
namespace billing followed by layout declarations |
| Module boundary | Module { path, exports, private_items } |
module Tax { ... } |
module Tax + indented body |
| State module | Module { kind: state } |
state module Store { ... } |
state module Store + indented body |
| Import edge | ImportEdge |
import billing::Tax::{addTax} |
same path form; calls use ML application |
| Signature | Signature { items } |
signature StoreSig { ... } |
signature StoreSig + indented items |
| Export | exported item metadata | export fn f(...) = ... |
export f : ... / export f x = ... |
| Symbol path | SymbolId { namespace, path } |
billing::Tax::addTax |
same path; application remains whitespace |
Namespaces
[MODULES-NAMESPACE] A namespace declaration contributes declarations to an
open logical namespace. Multiple files may contribute to the same namespace.
Namespace labels are opaque. billing, "billing/api", and "ui/forms" are
three unrelated labels; no parent namespace is implied.
namespaceDecl ::= "namespace" namespaceName ("{" statement* "}" | ";")
namespaceName ::= IDENT | STRING
symbolPath ::= IDENT ("::" IDENT)*
Default flavor:
namespace billing {
type Money = { cents: int, currency: string }
}
namespace billing {
fn zero(currency: string) -> Money = Money { cents: 0, currency: currency }
}
ML flavor:
namespace billing
type Money =
Money
cents : int
currency : string
zero : string -> Money
zero currency =
Money
cents = 0
currency = currency
The two declarations above define one namespace, billing. The compiler
merges namespace bodies before semantic analysis. Duplicate exported names in the
same namespace are compile-time errors unless they are overloads explicitly
allowed by a later overload spec.
Quoted labels allow slash-style names without overloading / inside ordinary
expressions:
namespace "billing/api";
The slash is part of the label. It does not create a billing parent namespace.
[MODULES-FILE-SCOPED-NAMESPACE] A file-scoped namespace declaration applies to
all declarations after it in the file:
Default flavor:
namespace billing;
type Invoice = { id: string, total: int }
fn emptyInvoice(id: string) = Invoice { id: id, total: 0 }
ML flavor:
namespace billing
type Invoice =
Invoice
id : string
total : int
emptyInvoice : string -> Invoice
emptyInvoice id =
Invoice
id = id
total = 0
A file may contain either one file-scoped namespace declaration or any number of block-scoped namespace declarations, not both.
[MODULES-PATH-INDEPENDENCE] The physical file path is never part of the
namespace identity. A file src/weird/place/x.osp may declare namespace billing;
or namespace "billing/api";. The compiler may warn when path and namespace
drift from project convention, but it must not change symbol identity or import
resolution.
[MODULES-NAMESPACE-STYLE] Namespace style is flexible but flat-first:
- Prefer one short lowercase label for app namespaces:
app,billing,ui,worker. - Use quoted slash labels only when the slash is part of a meaningful external
name, published package path, generated binding path, or project convention:
"billing/api","vendor/sqlite". - Avoid reverse-domain and three-part product hierarchies in ordinary app code. They are accepted for interoperability and distribution, but examples and docs must not present them as the normal shape.
- Never mirror folders by default. If a team chooses folder-like slash labels, the label remains opaque and path-independent.
Modules
[MODULES-MODULE] A module is a closed implementation boundary inside a
namespace. It may contain values, functions, types, effects, nested modules, and
private mutable state. It exports only declarations marked export or listed by
its signature.
moduleDecl ::= plainModuleDecl | stateModuleDecl
plainModuleDecl ::= "module" symbolPath signatureAscription? "{" moduleItem* "}"
stateModuleDecl ::= "state" "module" symbolPath signatureAscription? "{" moduleItem* "}"
signatureAscription ::= ":" symbolPath
moduleItem ::= exportDecl | statement
exportDecl ::= "export" statement
Default flavor:
namespace billing;
module Tax {
let defaultRate = 10
export fn addTax(cents: int) -> int =
cents + cents * defaultRate / 100
}
ML flavor:
namespace billing
module Tax
defaultRate = 10
export addTax : int -> int
export addTax cents =
cents + cents * defaultRate / 100
Tax.defaultRate is private. Tax.addTax is exported.
[MODULES-NAMESPACE-VS-MODULE] Namespaces are open and stateless. Modules are
closed and may own private implementation details. A namespace cannot be used as
a runtime value; a module can be referenced as a named declaration space and,
when it is a state module, has a runtime state owner.
Imports
[MODULES-IMPORT] Imports name namespaces or modules, not files.
importStmt ::= "import" importTarget importTail?
importTarget ::= namespaceName ("::" symbolPath)?
importTail ::= "as" IDENT
| "::" "{" importMember ("," importMember)* "}"
| "::" "*"
importMember ::= IDENT ("as" IDENT)?
Default flavor:
import billing::Tax
import billing::Tax::{addTax}
import billing::Tax as Tax
import "billing/api" as billingApi
let gross = addTax(100)
let other = Tax::addTax(100)
ML flavor:
import billing::Tax
import billing::Tax::{addTax}
import billing::Tax as Tax
import "billing/api" as billingApi
gross = addTax 100
other = Tax::addTax 100
Resolution rules:
- Identifier namespace labels can be used directly with
:::billing::Tax::addTax(100). - Quoted namespace labels must be imported with an alias before member access:
import "billing/api" as billingApi, thenbillingApi::Tax::addTax(100). import billing::Taxbrings the exported moduleTaxinto the local scope asTax.import billing::Tax::{x, y}brings only listed exported members into local scope.import billing::Tax as AliasbringsAliasinto local scope.import billing::Tax::*is allowed only in examples, scripts, and tests unless the project enablesallow_wildcard_imports = true; it is forbidden for state modules.- Ambiguous unqualified names are compile-time errors. The diagnostic must show every imported candidate and suggest qualification or aliasing.
Imports do not execute code, allocate module state, or load files by relative path.
Exports And Visibility
[MODULES-EXPORTS] Declarations are private by default inside modules and
public by default inside namespaces. A module controls its public surface through
export or a signature.
Default flavor:
module Parser {
type Token = { text: string } // private
export type Ast = Expr | Stmt
export fn parse(source: string) -> ResultAst, Error> = ...
}
ML flavor:
module Parser
type Token =
Token
text : string
export type Ast =
Expr | Stmt
export parse : string -> Result
export parse source =
...
[MODULES-OPAQUE-TYPES] A module may export an opaque type, hiding its
representation:
Default flavor:
module UserIds {
export opaque type UserId = int
export fn parseUserId(raw: string) -> ResultUserId, Error> = ...
export fn showUserId(id: UserId) -> string = ...
}
ML flavor:
module UserIds
export opaque type UserId = int
export parseUserId : string -> Result
export parseUserId raw =
...
export showUserId : UserId -> string
export showUserId id =
...
Outside UserIds, UserId is distinct from int. Inside UserIds, the
manifest representation is available. This is the Osprey form of ML abstract
types and Leroy-style manifest types.
Signatures
[MODULES-SIGNATURE] A signature is an explicit interface for a module. It
lists the names, types, effects, and opacity visible to clients.
signatureDecl ::= "signature" IDENT "{" signatureItem* "}"
signatureItem ::= typeSpec | effectSpec | fnSpec | moduleSpec
Default flavor:
signature StoreSig {
opaque type Store
effect StoreFx {
load : fn() -> Store
save : fn(Store) -> Unit
}
fn empty() -> Store
}
module MemoryStore : StoreSig {
export opaque type Store = { values: [string] }
export effect StoreFx {
load : fn() -> Store
save : fn(Store) -> Unit
}
export fn empty() = Store { values: [] }
}
ML flavor:
signature StoreSig
opaque type Store
effect StoreFx
load : Unit => Store
save : Store => Unit
empty : Unit -> Store
module MemoryStore : StoreSig
export opaque type Store =
Store
values : [string]
export effect StoreFx
load : Unit => Store
save : Store => Unit
export empty : Unit -> Store
export empty () =
Store
values = []
Signature conformance is checked structurally:
- Every signature item must have a matching exported declaration.
- Types must match after applying opacity rules.
- Effect operations must match names, parameter types, return types, and effect rows.
- Extra private declarations are allowed.
- Extra exported declarations are rejected unless the ascription is marked
: StoreSig + extra.
[MODULES-SEPARATE-CHECKING] A compiler may type-check an importing file using
only the imported module's signature. The implementation body is needed only
when compiling that module or linking the final project.
Parameterised Modules
[MODULES-FUNCTOR] A parameterised module is a module-level function from
signatures to modules. This is planned after basic signatures.
module MakeRepo(Db: DatabaseSig, Clock: ClockSig) : RepoSig {
export fn save(item: Item) -> Unit !Db.Database =
Db.insert(table: "items", value: encode(item, Clock.now()))
}
Parameterised modules are the dependency-injection mechanism for reusable libraries. They are preferred over ambient globals.
State Ownership
[MODULES-STATE] Mutable state may appear only in three places:
- inside a function or block as an ordinary local
mut; - inside an algebraic-effect handler's owned state region (EFFECTS-HANDLER-STATE);
- inside a
state module.
Namespace-level mut is a compile-time error.
namespace badState;
mut count = 0
// error [MODULES-STATE-TOPLEVEL]:
// mutable state must live in a function, handler, or state module
[MODULES-STATE-MODULE] A state module is the declared owner of durable
module state. All state cells are private, and no mut cell may be exported.
state module Counter {
mut count = 0
export effect CounterFx {
next : fn() -> int
read : fn() -> int
}
export let counterHandler = handler CounterFx {
next => {
count = count + 1
count
}
read => count
}
}
Clients perform the effect; the module owns the cell:
import Counter::{CounterFx, counterHandler}
fn allocate() -> int !CounterFx =
perform CounterFx.next()
handle counterHandler in {
print(toString(allocate()))
}
Rules:
state modulecells are private by construction.- Exporting a
mut, a pointer to amut, or a closure that directly exposes assignment is a compile-time error. - A
state modulemust export at least one handler, effect, or function that is the declared access path. - A namespace may contain at most one unannotated
state module. Additional state owners require@state_boundary("reason")and are reported by LSP and docs tooling as architecture-visible state boundaries. - Derived state should be expressed as pure functions over owner state. Cached
derived state is forbidden in Phase 1; a later
cache mutfeature must name the owner state it derives from, so invalidation can be checked.
[MODULES-STATE-SOURCE-OF-TRUTH] The compiler and tooling treat each state
module as a single source of truth for the state it owns. Cross-module writes
are impossible. Cross-module reads happen through exported pure queries or
effect operations. This is the language-level answer to scattered app state.
Effects And Capabilities
[MODULES-EFFECTS] Modules do not hide effects. Exported functions and handlers
carry ordinary Osprey effect rows. Importing a module never grants ambient
permission; a caller must still handle or forward every effect.
State modules are encouraged to expose capabilities as algebraic effects:
signature LedgerSig {
effect Ledger {
post : fn(int) -> int
balance : fn() -> int
}
}
This keeps application logic pure except for explicit !Ledger, while the
module decides whether state is in memory, SQLite, HTTP, or a test fake.
Initialisation
[MODULES-INIT] Imports have no runtime effect. Module initialization is explicit.
- Pure
letdeclarations may be evaluated at compile time or lowered as constants. - Effectful setup must live in an exported
initfunction or handler factory. state moduleinitial state is allocated only when its handler or instance is explicitly constructed.- Cyclic initialization between state modules is a compile-time error.
state module DbStore {
mut conn = None
export fn init(path: string) -> Unit !Database =
conn = Some(perform Database.open(path))
}
Project Assembly
[MODULES-PROJECT] A project compile scans configured source roots, parses every
.osp and .ospml file, resolves each file's flavor, and builds one project
namespace graph.
[project]
name = "billing"
source_roots = ["src", "tests"]
default_namespace = "billing"
[modules]
allow_wildcard_imports = false
Single-file mode remains valid for scripts and examples. Project mode adds:
- all source files in the project graph;
- namespace merge;
- import resolution;
- signature checking;
- duplicate-name and ambiguity diagnostics;
- one entry point.
[MODULES-ENTRYPOINT] In project mode, executable top-level statements are
allowed only in the designated entry file or in fn main(). Library files must
contain declarations only. This avoids hidden initialization order and makes
multi-file apps deterministic.
Cycles
[MODULES-CYCLES] Namespace declarations may be mutually visible after merging,
but module implementation cycles are restricted.
- Pure type/function cycles are allowed only when ordinary Osprey recursion rules allow them.
- Signature cycles are allowed only through explicit opaque types.
state modulecycles are rejected.- Parameterised modules may depend on signatures, not implementation bodies, to preserve separate compilation.
Recursive modules are a later feature and must require explicit signatures, as in the ML literature.
Name Mangling And ABI
[MODULES-ABI] Canonical symbol names include the namespace label and :: path:
billing::Tax::addTax
Codegen must mangle symbol paths deterministically and collision-free. The mangled form is an implementation detail; diagnostics, docs, LSP, debugger, and stack traces use source-level names.
Cross-flavor exports use the same ABI rules as Cross-Flavor Interop.
Diagnostics
[MODULES-DIAG] Module diagnostics must be architecture-facing:
- unknown import: show candidate namespaces from the project graph;
- ambiguous import: show all providers and suggest aliases;
- exported private dependency: show the hidden type/value in the public signature;
- state scatter: show every state module in the namespace and require
@state_boundary; - top-level mutable state: suggest
state moduleor handler-owned state; - path drift: warn, never change semantics.
Examples
Multi-file, Path-Independent Namespace
src/a.osp:
namespace app;
fn hello(name: string) = "Hello ${name}"
src/deeply/nested/b.ospml:
namespace app
greet name = hello name
Both files contribute to app. The path deeply/nested is irrelevant.
src/http.osp shows the optional slash label:
namespace "app/http";
fn route() = "/"
That namespace is unrelated to app; import it with an alias when used from
ordinary expressions:
import "app/http" as httpApp
let root = httpApp::route()
Centralised State
namespace app;
state module SessionStore {
mut sessions = []
export effect Sessions {
add : fn(string) -> Unit
count : fn() -> int
}
export let liveSessions = handler Sessions {
add id => { sessions = listAppend(sessions, id) }
count => listLength(sessions)
}
}
fn login(id: string) -> Unit !Sessions =
perform Sessions.add(id)
Application code cannot mutate sessions. It can only perform Sessions.
References
- David L. Parnas. "On the Criteria To Be Used in Decomposing Systems into Modules." Communications of the ACM, 1972.
- David MacQueen. "Modules for Standard ML." LFP, 1984.
- John C. Mitchell and Gordon D. Plotkin. "Abstract Types Have Existential Type." POPL 1985 / TOPLAS 1988.
- Robert Harper and Mark Lillibridge. "A Type-Theoretic Approach to Higher-Order Modules with Sharing." POPL 1994.
- Xavier Leroy. "Manifest Types, Modules, and Separate Compilation." POPL 1994.
- Xavier Leroy. "Applicative Functors and Fully Transparent Higher-Order Modules." POPL 1995.
- Xavier Leroy. "A Modular Module System." JFP, 2000.
- Karl Crary, Robert Harper, and Sidd Puri. "What is a Recursive Module?" PLDI 1999.
- Keiko Nakata and Jacques Garrigue. "Recursive Modules for Programming." ICFP 2006.
- Andreas Rossberg, Claudio V. Russo, and Derek Dreyer. "F-ing Modules." TLDI 2010 / JFP.
- Andreas Rossberg. "1ML - Core and Modules United." ICFP 2015 / JFP.
- Scott Kilpatrick, Derek Dreyer, Simon Peyton Jones, and Simon Marlow. "Backpack: Retrofitting Haskell with Interfaces." POPL 2014.
- Gordon Plotkin and Matija Pretnar. "Handlers of Algebraic Effects." ESOP 2009.
- John Launchbury and Simon L. Peyton Jones. "Lazy Functional State Threads." PLDI 1994.
- Simon Peyton Jones and Philip Wadler. "Imperative Functional Programming." POPL 1993.
- Ben Moseley and Peter Marks. "Out of the Tar Pit." 2006.
- Jean-Philippe Bernardy, Mathieu Boespflug, Ryan R. Newton, Simon Peyton Jones, and Arnaud Spiwack. "Linear Haskell: Practical Linearity in a Higher-Order Polymorphic Language." POPL 2018.
- Cong Ma, Zhaoyi Ge, Edward Lee, and Yizhou Zhang. "Lexical Effect Handlers, Directly." OOPSLA 2024.
- Microsoft. ".NET namespace guidance."
- Microsoft. "F# Namespaces" and "F# Modules."
- OCaml. "The OCaml Manual - The Module System."
- Simon Marlow, editor. "Haskell 2010 Language Report", Chapter 5, Modules.
- Elm. "Modules." Elm Guide.
- Clojure. "Namespaces." Clojure Guides.
- Oracle. "Java Language Specification", Section 6.1, Names.
- Redux. "Three Principles."