Sigil Type System
Sigil uses bidirectional type checking with explicit function, lambda, and effect annotations.
This page describes the current implemented system, not older design ideas.
Current Model
Implemented today:
- bidirectional type checking
- explicit top-level parametric polymorphism
- algebraic data types
- exact records
- map types
- solver-backed type refinements
- nominal type labels
- function contracts
- explicit effect annotations
Not implemented today:
- Hindley-Milner let-polymorphism
- generic lambdas
- call-site type arguments like
f[Int](x) - borrow checking
- ownership/lifetimes
Explicit Types
Function and lambda signatures must be fully typed. Sigil does not implement general-purpose let-polymorphism or broad signature inference, but ordinary expression checking is still bidirectional rather than requiring every subexpression to carry an explicit :T.
Examples:
c pi=(3.14:Float)
total λfactorial(n:Int)=>Int
requires n≥0
decreases n
match n{
0=>1|
1=>1|
value=>value*factorial(value-1)
}
λ(x:Int)=>Int=x*2
Missing parameter or return type annotations are parse errors.
Function Modes
Top-level functions are ordinary by default.
A file may place mode total once at the top to make total the default for later function declarations. Any individual declaration may override that default with total λname... or ordinary λname....
Rules:
- only total self-recursive functions may declare
decreases - ordinary self-recursive functions may recurse without a termination proof
- total functions may not call declarations marked
ordinary
Type Ascription
Type ascription uses one explicit expression form:
(expr:Type)
Examples:
c airAccel=(1:Int)
λmain()=>Int={
l speed=(1:Int);
speed+speed
}
Sigil intentionally keeps this as one uniform rule. It does not use separate binding-level surfaces like c name:Type=value, and it does not use bare postfix expression forms like expr:Type.
The goal is canonical simplicity for humans and LLMs:
- if you want to ascribe a type to an expression, write
(expr:Type) - the same rule applies everywhere
- there is no second declaration-specific rule to learn
The tradeoff is extra parentheses. Sigil accepts that cost to keep one teachable surface instead of multiple equivalent annotation forms.
Top-Level Generics
Sigil supports explicit generic declarations at top level:
λidentity[T](x:T)=>T=x
λmapOption[T,U](fn:λ(T)=>U,opt:Option[T])=>Option[U]
Polymorphism comes from those declarations. Local l bindings remain monomorphic.
Type Forms
Primitive types:
IntFloatBoolStringCharUnitNever
Never is Sigil's terminating expression type:
- an expression of type
Nevermay be used anywhere another result type is expected - this is one-way compatibility for expression checking, not a general subtyping feature
Nevermarks code paths that do not continue, such as§process.exit(...)
Compound forms:
- lists:
[T] - maps:
{K↦V} - functions:
λ(T1,T2,...)=>R - owned resources:
Owned[T] - named ADTs and aliases
Owned[T] is the compiler-known resource wrapper type.
Current ownership rules:
Owned[T]values are introduced by stdlib resource creators and typed extern subscriptions- they are intended to be consumed with
using - borrowed resource values inside
usingscopes must not escape the scope Owned[T]is affine rather than freely duplicable; Sigil rejects ordinarylbindings of owned values and rejects storing them inside ordinary list/record/map literals
Feature Flag Types
First-class featureFlag declarations currently support:
Bool- named sum types
Examples:
t CheckoutColor=Citrus()|Control()|Ocean()
featureFlag NewCheckout:Bool
createdAt "2026-04-12T14-00-00Z"
default false
featureFlag CheckoutColorChoice:CheckoutColor
createdAt "2026-04-12T14-00-00Z"
default Control()
Current rules:
- the declared
defaultmust typecheck against the flag type - the default expression must be pure
- flag descriptors are ordinary typed values and may be passed to
§featureFlags.get
Project Types
In projects with sigil.json, project-defined named types live in src/types.lib.sigil and are referenced elsewhere as µTypeName.
Example:
t BirthYear=Int where value>1800 and value<10000
t User={
birthYear:BirthYear,
name:String
}
λtodoId(todo:µTodo)=>Int=todo.id
src/types.lib.sigil owns t, label, and label ... combines ... declarations. Type definitions and constraints may reference only §... and ¶....
Labelled Types
Sigil separates type membership from type classification.
where describes what values belong to a type. label describes what kind of data a value represents for boundary handling.
Example:
label Brazil
label Credential
label GovAuth
label Pii
label Usa
t Cpf=String label [Brazil,Pii]
t GovBrToken=String label [Brazil,Credential,GovAuth]
t Ssn=String label [Pii,Usa]
Rules:
- labels are nominal classifications, not predicates over
value label X combines Yadds implied labels during checking- labelled values behave like ordinary values inside local computation
- labels matter when a labelled value crosses a named topology boundary
- direct consumers must handle directly exposed labelled data at their own boundaries
- unlabeled data is unaffected by boundary-rule checking
Projects pair labelled types with boundary rules in src/policies.lib.sigil. That file owns rule and transform declarations.
Topology-aware labelled-boundary tests run under sigil test --env and assert the resulting boundary behavior directly with named-boundary helpers such as:
※check::file.existsAt(path,•topology.exportsDir)※check::log.containsAt(message,•topology.auditLog)※observe::process.commandsAt(•topology.govBrCli)
Records and Maps
Records and maps are different concepts:
- records are exact structural products using
: - maps are dynamic keyed collections using
↦
Examples:
t User={
id:Int,
name:String
}
t Scores={String↦Int}
Current Sigil has:
- no row polymorphism
- no open records
- no width subtyping
If a field may be absent, use Option[T] in an exact record.
Constrained Types
Named user-defined types may carry a pure where clause:
t BirthYear=Int where value>1800 and value<10000
t DateRange={
end:Int,
start:Int
} where value.end≥value.start
Current rules:
- only
valueis in scope inside the constraint - the constraint must typecheck to
Bool - constraints are pure and world-independent
- constrained aliases and constrained named product types act as compile-time refinements over their underlying type
- values may flow into a constrained type only when the checker can prove the predicate in Sigil's canonical solver-backed refinement fragment
- constrained values widen to their underlying type automatically
- the current proof fragment covers Bool/Int literals,
value, field access,#over strings/lists/maps,+,-, comparisons,and,or, andnot - control flow is part of that proof story:
match, exact record patterns, and internal branching propagate supported branch facts into refinement checking - direct boolean local aliases of supported facts also narrow
- there is no generated runtime validation in v1
Example:
t BirthYear=Int where value>1800
λpromote(year:Int)=>BirthYear match year>1800{
true=>year|
false=>1900
}
Runnable examples:
language/examples/functionContracts.sigillanguage/examples/proofMeasures.sigil
Function Contracts
Functions may also carry pure compile-time contracts:
t BirthYear=Int where value>1800
λnormalizeYear(raw:Int)=>Int
ensures result>1800
match raw>1800{
true=>raw|
false=>1900
}
λpositiveGap(baseline:Int,current:BirthYear)=>Int
requires current≥baseline
ensures result≥0
=current-baseline
Current rules:
requiresis checked at call sitesensuresis checked against the function body and then flows back to callers as a proven fact- each function may declare at most one
requiresclause and at most oneensuresclause requiresmay reference only parametersensuresmay reference parameters plusresult- contracts must typecheck to
Bool - contracts are pure and world-independent
- effectful functions may still carry contracts, but the contracts describe only parameter obligations and returned-value guarantees
- contracts use the same solver-backed proof fragment as constrained types and branch narrowing
- contracts do not inject runtime checks
That makes where, requires, and ensures complementary:
wheredefines membership in a typerequiresstates what a caller must prove before a callensuresstates what the callee guarantees after the call returns
The same surfaces now appear in runnable project code too:
projects/todo-app/src/todoDomain.lib.sigilprojects/flashcards/src/flashcardsDomain.lib.sigilprojects/algorithms/src/fibonacciSearch.lib.sigilprojects/game-2048/src/game2048.lib.sigilprojects/minesweep/src/minesweepDomain.lib.sigilprojects/roguelike/src/roguelike.lib.sigil
Type Equality
Sigil normalizes unconstrained aliases and unconstrained named product types before equality-sensitive checks.
That means:
- unconstrained aliases compare structurally
- unconstrained named product types compare structurally after normalization
- constrained aliases and named product types use refinement checking over their underlying type instead of plain structural equality
- sum types remain nominal
Effects
Effect annotations are part of the current surface. Sigil ships with primitive effects:
ClockFsFsWatchHttpLogProcessPtyRandomSqlStreamTaskTcpTerminalTimerWebSocket
Projects may define reusable multi-effect aliases only in src/effects.lib.sigil. Aliases must expand to at least two primitive effects.
Example:
effect CliIo=!Fs!Log!Process
Examples:
e axios:{get:λ(String)=>!Http String}
e console:{log:λ(String)=>!Log Unit}
λfetch()=>!Http String=axios.get("https://example.com")
λmain()=>!Http!Log Unit={
l _=(fetch():String);
console.log("hello")
}
Tests can also declare effects:
λmain()=>Unit=()
test "captured log contains line" =>!Log world {
c log=(†log.capture():†log.LogEntry)
} {
l _=(§io.println("captured"):Unit);
※check::log.contains("captured")
}
The checker enforces exact effect annotations. If a body or callee requires !Fs, !Http, or any other declared effect, the enclosing signature must declare that effect. Declaring extra unused effects is rejected too. Sigil checks the body's static may-effect set after named-effect expansion, not a loose upper bound.
Canonical Typed Rules
Some canonical rules depend on type information.
Current important example:
- a pure local binding used exactly once is rejected and must be inlined
- a wildcard sequencing binding must not discard a pure expression
This happens after type checking as part of typed canonical validation.
Trusted Internal Data
Sigil wants business logic to operate on validated internal values rather than raw boundary data.
Canonical shape:
raw input
=> parse
=> decode / validate
=> exact internal record or named wrapper
Examples:
t Email=Email(String)
t Message={
createdAt:§time.Instant,
text:String
}
Source of Truth
When prose and implementation disagree, current truth comes from:
language/compiler/crates/sigil-typechecker/- runnable examples and tests
- canonical validation behavior