This language is a new statically typed, functional, macro-capable language with Elixir-like syntax and a native backend strategy built around lowering through a Zig-oriented compiler pipeline.
It is:
- Elixir-shaped at the surface
- statically typed
- functionally oriented
- macro-capable
- indentation-significant
- block-only
- overload-aware
- locally scoped with fallback dispatch across scopes
- compiled natively
- not BEAM-based
- not Gleam
- not Zig syntax
This document is the canonical single specification for the language, compiler architecture, runtime model, and implementation plan.
The language preserves these visible Elixir-family forms:
defmoduledefdefmacrotypeopaquedo ... end- tuples
- tagged tuples
ifcasewithcondquoteunquote
It does not preserve all Elixir semantics. It uses Elixir-like syntax for a new typed functional language.
Types are declared inline in function headers and in standalone type declarations.
There is no primary @spec or @type metadata model.
The language forbids shorthand body forms like , do:.
All body-bearing forms use blocks.
Indentation is part of the grammar.
Formatting is not merely style. Layout participates in parsing.
Functions are declared with def (public) or defp (module-private) at module scope, and with def at local scope inside other functions.
There is no separate core anonymous function syntax required by the design.
Any function-valued expression is called with ordinary parentheses:
f(2)There is no f.(2) form.
A local function family is tried first.
If it does not match, dispatch continues outward to enclosing scopes, including module scope.
This is not ordinary lexical shadowing.
The language is functionally oriented:
- immutable bindings by default
- pure functions by default
- structural pattern matching
- persistent data structures
- explicit effect boundaries
The language supports:
defmoduletypeopaquedefdefpdefstructdefmacroaliasimport
The following is invalid:
def foo(x :: Int) :: Int, do: xThe following is valid:
def foo(x :: Int) :: Int do
x
endThis applies uniformly to:
- functions
- macros
ifcasewithcond
Whitespace is significant.
The lexer emits:
NEWLINEINDENTDEDENT
Rules:
- tabs and spaces may not be mixed for indentation
- inconsistent dedentation is a syntax error
- indentation that does not match an open block is a syntax error
- misaligned
endis a syntax error
do and end remain mandatory even though layout is significant.
That means structure is enforced by both:
- indentation
- explicit delimiters
The language supports:
- numeric types:
- signed integers:
i8,i16,i32,i64 - unsigned integers:
u8,u16,u32,u64 - floats:
f16,f32,f64 - platform-sized:
usize,isize
- signed integers:
- other primitives:
Bool,String,Atom,Nil - bottom type:
Never(the type of expressions that never produce a value) - tuple types
- tagged tuple types
- list types
- map types
- struct types
- union types
- function types
- parametric types
- opaque types
- named type aliases
Type aliases are declared with type:
type Result(a, e) = {:ok, a} | {:error, e}
type Pair(a, b) = {a, b}
type Mapper(a, b) = (a -> b)
type Byte = u8Opaque types are declared with opaque:
opaque UserId = i64Tagged tuples are the primary algebraic-data representation.
Example:
type Expr =
{:int, i64}
| {:add, Expr, Expr}
| {:var, String}All Zig numeric types are exposed directly. There is no implicit coercion or promotion between numeric types. Developers must explicitly convert:
# OK: same types
def add(x :: i64, y :: i64) :: i64 do
x + y
end
# Compile error: no overload for +(i32, i64)
def bad(x :: i32, y :: i64) :: i64 do
x + y
end
# Correct: explicit conversion
def good(x :: i32, y :: i64) :: i64 do
i32_to_i64(x) + y
endArithmetic, comparison, and all numeric operators require both operands to be the same numeric type.
Functions declare parameter and return types inline.
Example:
def add(x :: i64, y :: i64) :: i64 do
x + y
endParameter and return annotations are hard contracts.
Types inside function bodies may be inferred.
Function boundaries are explicit and authoritative.
Pattern annotations refine both shape and bindings.
Example:
def unwrap({:ok, x} :: Result(i64, e)) :: i64 do
x
endInside the body, x is known to be i64.
Generic type variables are supported in type and function declarations.
Example:
def map(value :: Result(a, e), f :: (a -> b)) :: Result(b, e) do
case value do
{:ok, x} ->
{:ok, f(x)}
{:error, err} ->
{:error, err}
end
endThe canonical function form is:
def name(params...) :: ReturnType do
...
endWith optional refinement:
def name(params...) :: ReturnType if predicate do
...
endParameters use patterns with optional type annotations:
param := pattern [ "::" type_expr ]Examples:
def id(x :: i64) :: i64 do
x
enddef unwrap({:ok, x} :: Result(i64, e)) :: i64 do
x
endReturn annotations appear after the parameter list:
def foo(x :: i64) :: String do
int_to_string(x)
endReturn annotations are hard contracts.
A function body that can produce a non-conforming value is rejected.
Refinements are written with if in the function header.
Example:
def abs(x :: i64) :: i64 if x < 0 do
-x
end
def abs(x :: i64) :: i64 do
x
endA refinement predicate is part of clause applicability.
A clause matches only if:
- its type constraints are applicable
- its pattern matches
- its refinement evaluates to
true
Refinement predicates must be:
- pure
- side-effect free
- non-mutating
- deterministic
- runtime-safe for dispatch filtering
Refinements cannot do:
- IO
- mutation
- arbitrary side effects
Refinements are allowed anywhere clause-like syntax exists, including:
- function clauses
- local function clauses
casebrancheswithelse branches
Any expression of function type can be called with normal parentheses.
Examples:
f(2)make_adder(1)(2)The language does not use a distinct anonymous-function call syntax.
The parser treats all applications uniformly.
The type checker determines whether a call is:
- direct function-value application
- named function-family dispatch
A function can be declared inside another function body using def.
Example:
def outer(x :: i64) :: String do
def inner(s :: String) :: String do
s <> "!"
end
inner("ok")
endA local function is not mere syntax sugar for variable-bound lambda syntax.
It is a real function declaration with:
- scope
- overload family membership
- clause matching
- capture analysis
- possible recursion
- possible mutual recursion
Local function declarations are hoisted within their lexical block group.
That means:
- all local defs in a block are collected before body checking
- recursion is supported
- mutual recursion among sibling local defs is supported
- overload families can be formed before type checking bodies
This is the required rule.
A local function may capture bindings from outer lexical scopes.
Captured values become closure environment state in lowering.
Functions of the same name and arity in the same lexical scope form an overload family.
Overload resolution uses:
- name
- arity
- argument types
Return type does not participate in overload selection.
If multiple overloads are applicable, the compiler chooses the uniquely most specific one.
If no uniquely most specific candidate exists, the program is rejected as ambiguous.
Concrete overloads outrank generic overloads when one is strictly more specific.
Ambiguous generic applicability is an error.
Overload resolution happens before intra-family clause matching.
That means:
- find applicable overload candidates by type
- choose most specific
- perform clause matching and refinement filtering inside that family
This is one of the defining language features.
For an unqualified call b(args...) inside a nested scope:
- try the innermost scope’s
b/arityfamily - if no family exists, continue outward
- if a family exists, attempt overload resolution
- if no overload is applicable, continue outward
- if overload resolution is ambiguous, compilation fails
- if an overload family is applicable, attempt clause matching
- if no clause matches, continue outward
- if a clause matches, dispatch succeeds
- try enclosing local scopes (repeat 1-8 outward)
- try the current module scope
- try the import scope (all imported function families, unified)
- try the prelude scope (auto-imported
Kernelfunctions) - if no scope matches, report a no-match error
For a qualified call Module.f(args...):
- resolve
Module(expand aliases if needed) - look up
f/aritydirectly in the target module’s public scope - apply overload resolution within that module
- no fallback — qualified calls are direct
Inner scopes do not completely shadow outer scopes.
Instead, inner scopes have first right of refusal.
Outer scopes remain valid fallback targets.
defmodule Foo do
def b(s :: String) :: String do
s <> "foo"
end
def a(x :: i64) :: String do
def b(n :: i64) :: String do
int_to_string(n)
end
b("other")
end
endDispatch for b("other") inside a:
- local
b(i64)family exists - argument type
Stringis not applicable - local scope fails
- module scope is tried
- module
b(String)matches - module function is used
defmodule Foo do
def b(s :: String) :: String do
s <> "foo"
end
def a(x :: i64) :: String do
def b(s :: String) :: String if string_length(s) < 5 do
s <> "bar"
end
b("other")
end
endDispatch for b("other") inside a:
- local
b(String)is applicable - refinement is checked
- if refinement passes, local function is used
- if refinement fails, fallback continues
- module
b(String)is tried - module function is used if it matches
If a call can resolve through multiple fallback layers, all reachable successful resolution paths must produce a coherent type.
Otherwise the call is rejected.
Pattern matching is used in:
- function parameters
- local functions
casewith- assignment destructuring if supported
Supported patterns:
- wildcard
- bind
- literal
- tuple
- list
- map
- struct
- pin
- parenthesized pattern
Pattern matching performs:
- structural tests
- variable bindings
- type refinements
- optional refinement predicate evaluation
The compiler should perform exhaustiveness checking where practical, especially for:
case- union-typed matches
- tagged union matches
Macros are AST-to-AST transforms.
A macro:
- receives AST
- returns AST
- expands before full body type checking
Macros use defmacro:
defmacro unless(expr :: AST, body :: AST) :: AST do
quote do
if not unquote(expr) do
unquote(body)
end
end
endCompilation phases involving macros:
- parse source into surface AST
- collect declarations and macro availability
- expand macros to a fixed point
- desugar expanded AST
- continue into resolution and typing
Macro-generated bindings carry hidden identity information, not just textual names.
Hygienic identity includes:
- name
- generation context
- generation counter
Generated names do not capture user names accidentally.
Macros expand in a lexical environment with access to:
- current module
- local aliases/imports/requirements if supported
- caller metadata
- quoted/unquoted context
Initial macro support excludes:
- parser-changing macros
- syntax-extension macros
- post-typecheck AST mutation
- unrestricted side-effectful compile-time execution
letter = "A"…"Z" | "a"…"z" | "_" ;
digit = "0"…"9" ;
ident = letter , { letter | digit | "!" | "?" } ;
module_ident = ident , { "." , ident } ;
type_ident = ident ;
int_lit = digit , { digit } ;
float_lit = digit , { digit } , "." , digit , { digit } ;
string_lit = "\"" , { string_part } , "\"" ;
string_part = string_char | string_interp ;
string_char = (* any character except `"` and `#` followed by `{` *) ;
string_interp = "#" , "{" , expr , "}" ;
atom_lit = ":" , ident ;
bool_lit = "true" | "false" ;
nil_lit = "nil" ;
numeric_type = "i8" | "i16" | "i32" | "i64"
| "u8" | "u16" | "u32" | "u64"
| "f16" | "f32" | "f64"
| "usize" | "isize" ;program = { top_decl | newline } ;
top_decl = module_decl
| type_decl
| opaque_decl
| fun_decl
| priv_fun_decl
| macro_decl ;module_decl = "defmodule" , module_ident , "do" , newline ,
indent ,
{ module_body_item | newline } ,
dedent ,
"end" ;
module_body_item = type_decl
| opaque_decl
| struct_decl
| fun_decl
| priv_fun_decl
| macro_decl
| alias_decl
| import_decl ;type_decl = "type" , type_name , [ type_params ] , "=" , type_expr ;
opaque_decl = "opaque" , type_name , [ type_params ] , "=" , type_expr ;
type_name = type_ident ;
type_params = "(" , type_param , { "," , type_param } , ")" ;
type_param = type_ident ;struct_decl = "defstruct" , "do" , newline ,
indent ,
{ struct_field_decl | newline } ,
dedent , "end" ;
struct_field_decl= ident , "::" , type_expr , [ "=" , expr ] ;fun_decl = "def" , fun_name , param_clause , [ return_annot ] ,
[ refine_clause ] , "do" , newline ,
indent , block , dedent , "end" ;
priv_fun_decl = "defp" , fun_name , param_clause , [ return_annot ] ,
[ refine_clause ] , "do" , newline ,
indent , block , dedent , "end" ;
macro_decl = "defmacro" , fun_name , param_clause , [ return_annot ] ,
[ refine_clause ] , "do" , newline ,
indent , block , dedent , "end" ;
fun_name = ident ;
param_clause = "(" , [ param_list ] , ")" ;
param_list = param , { "," , param } ;
param = pattern , [ "::" , type_expr ] ;
return_annot = "::" , type_expr ;
refine_clause = "if" , expr ;alias_decl = "alias" , module_path , [ "," , "as:" , module_ident ] ;
import_decl = "import" , module_path , [ "," , import_filter ] ;
import_filter = "only:" , "[" , import_entry_list , "]"
| "except:" , "[" , import_entry_list , "]" ;
import_entry_list= import_entry , { "," , import_entry } ;
import_entry = fun_name , ":" , int_lit
| "type:" , type_ident ;
module_path = module_ident
| module_ident , ".{" , module_ident_list , "}" ;
module_ident_list= module_ident , { "," , module_ident } ;block = { stmt | newline } ;
stmt = local_fun_decl
| local_macro_decl
| local_import_decl
| assign_stmt
| expr_stmt ;
local_fun_decl = fun_decl ;
local_macro_decl = macro_decl ;
local_import_decl= import_decl ;
assign_stmt = pattern , "=" , expr ;
expr_stmt = expr ;expr = logic_or_expr ;
logic_or_expr = logic_and_expr , { "or" , logic_and_expr } ;
logic_and_expr = compare_expr , { "and" , compare_expr } ;
compare_expr = pipe_expr ,
{ ("==" | "!=" | "<" | ">" | "<=" | ">=") , pipe_expr } ;
pipe_expr = add_expr , { "|>" , add_expr } ;
add_expr = mul_expr , { ("+" | "-" | "<>") , mul_expr } ;
mul_expr = unary_expr , { ("*" | "/" | "rem") , unary_expr } ;
unary_expr = [ "-" | "not" ] , postfix_expr ;
postfix_expr = call_expr , [ "!" ] ;call_expr = primary_expr , { call_suffix | access_suffix } ;
call_suffix = "(" , [ arg_list ] , ")" ;
arg_list = expr , { "," , expr } ;
access_suffix = "." , ident ;primary_expr = literal
| var_ref
| tuple_expr
| list_expr
| map_expr
| struct_expr
| paren_expr
| if_expr
| case_expr
| with_expr
| cond_expr
| quote_expr
| unquote_expr
| panic_expr ;
panic_expr = "panic" , "(" , expr , ")" ;
literal = int_lit
| float_lit
| string_lit
| atom_lit
| bool_lit
| nil_lit ;
var_ref = ident ;
paren_expr = "(" , expr , ")" ;tuple_expr = "{" , [ expr_list ] , "}" ;
expr_list = expr , { "," , expr } ;
list_expr = "[" , [ expr_list ] , "]" ;
map_expr = "%{" , [ map_field_list ] , "}" ;
map_field_list = map_field , { "," , map_field } ;
map_field = expr , "=>" , expr ;
struct_expr = "%" , module_ident , "{" ,
[ struct_update_source , "|" ] ,
[ struct_field_list ] , "}" ;
struct_update_source = expr ;
struct_field_list= struct_field , { "," , struct_field } ;
struct_field = ident , ":" , expr ;if_expr = "if" , expr , "do" , newline ,
indent , block , dedent ,
[ "else" , newline , indent , block , dedent ] ,
"end" ;
case_expr = "case" , expr , "do" , newline ,
indent , case_clause , { newline , case_clause } ,
dedent , "end" ;
case_clause = pattern , [ "::" , type_expr ] ,
[ "if" , expr ] , "->" , newline ,
indent , block , dedent ;
with_expr = "with" , with_item , { "," , with_item } ,
"do" , newline ,
indent , block , dedent ,
[ "else" , newline , indent , with_else_clause ,
{ newline , with_else_clause } , dedent ] ,
"end" ;
with_item = pattern , "<-" , expr
| expr ;
with_else_clause = pattern , [ "::" , type_expr ] ,
[ "if" , expr ] , "->" , newline ,
indent , block , dedent ;
cond_expr = "cond" , "do" , newline ,
indent , cond_clause , { newline , cond_clause } ,
dedent , "end" ;
cond_clause = expr , "->" , newline , indent , block , dedent ;quote_expr = "quote" , "do" , newline ,
indent , block , dedent , "end" ;
unquote_expr = "unquote" , "(" , expr , ")" ;pattern = wildcard_pattern
| bind_pattern
| literal_pattern
| tuple_pattern
| list_pattern
| map_pattern
| struct_pattern
| pin_pattern
| paren_pattern ;
wildcard_pattern = "_" ;
bind_pattern = ident ;
literal_pattern = literal ;
tuple_pattern = "{" , [ pattern_list ] , "}" ;
pattern_list = pattern , { "," , pattern } ;
list_pattern = "[" , [ pattern_list ] , "]" ;
map_pattern = "%{" , [ map_pattern_field_list ] , "}" ;
map_pattern_field_list
= map_pattern_field , { "," , map_pattern_field } ;
map_pattern_field= expr , "=>" , pattern ;
struct_pattern = "%" , module_ident , "{" ,
[ struct_pattern_field_list ] , "}" ;
struct_pattern_field_list
= struct_pattern_field , { "," , struct_pattern_field } ;
struct_pattern_field
= ident , ":" , pattern ;
pin_pattern = "^" , ident ;
paren_pattern = "(" , pattern , ")" ;type_expr = type_union ;
type_union = type_term , { "|" , type_term } ;
type_term = type_fun
| type_tuple
| type_list
| type_map
| type_struct
| type_app
| type_atom
| type_literal
| type_numeric
| type_never
| type_var
| "(" , type_expr , ")" ;
type_numeric = numeric_type ;
type_never = "Never" ;
type_fun = "(" , [ type_expr_list ] , "->" , type_expr , ")" ;
type_expr_list = type_expr , { "," , type_expr } ;
type_tuple = "{" , [ type_expr_list ] , "}" ;
type_list = "[" , type_expr , "]" ;
type_map = "%{" , [ type_map_field_list ] , "}" ;
type_map_field_list
= type_map_field , { "," , type_map_field } ;
type_map_field = type_expr , "=>" , type_expr ;
type_struct = "%" , module_ident , "{" ,
[ type_struct_field_list ] , "}" ;
type_struct_field_list
= type_struct_field , { "," , type_struct_field } ;
type_struct_field= ident , ":" , type_expr ;
type_app = type_ident , [ "(" , type_expr_list , ")" ] ;
type_atom = atom_lit ;
type_literal = int_lit | string_lit | bool_lit | nil_lit ;
type_var = type_ident ;NodeMeta {
span: SourceSpan
scope_id: ScopeId
}
TypedMeta {
span: SourceSpan
scope_id: ScopeId
ty: TypeId
}
Program {
modules: [ModuleDecl]
items: [TopItem]
}
ModuleDecl {
meta: NodeMeta
name: ModuleName
items: [ModuleItem]
}
ModuleItem =
| TypeDecl
| OpaqueTypeDecl
| FunctionGroupDecl
| MacroGroupDecl
TypeDecl {
meta: NodeMeta
name: SymbolId
params: [TypeParam]
body: TypeExpr
}
OpaqueTypeDecl {
meta: NodeMeta
name: SymbolId
params: [TypeParam]
body: TypeExpr
}
TypeParam {
meta: NodeMeta
name: SymbolId
}
FunctionGroupDecl {
meta: NodeMeta
name: SymbolId
arity: Int
clauses: [FunctionClause]
scope_level: ScopeLevel
}
MacroGroupDecl {
meta: NodeMeta
name: SymbolId
arity: Int
clauses: [MacroClause]
scope_level: ScopeLevel
}
FunctionClause {
meta: NodeMeta
params: [TypedPattern]
return_type: TypeExpr?
refinement: Expr?
body: BlockExpr
captures: [CaptureId]
effect: EffectInfo
}
MacroClause {
meta: NodeMeta
params: [TypedPattern]
return_type: TypeExpr?
refinement: Expr?
body: BlockExpr
}
TypedPattern {
meta: NodeMeta
pattern: Pattern
annotation: TypeExpr?
}
Pattern =
| WildcardPattern
| BindPattern
| LiteralPattern
| TuplePattern
| ListPattern
| MapPattern
| StructPattern
| PinPattern
Pattern nodes:
WildcardPattern { meta: NodeMeta }
BindPattern {
meta: NodeMeta
symbol: SymbolId
}
LiteralPattern {
meta: NodeMeta
value: Literal
}
TuplePattern {
meta: NodeMeta
items: [Pattern]
}
ListPattern {
meta: NodeMeta
items: [Pattern]
}
MapPattern {
meta: NodeMeta
fields: [MapPatternField]
}
StructPattern {
meta: NodeMeta
module: ModuleName
fields: [StructPatternField]
}
PinPattern {
meta: NodeMeta
symbol: SymbolId
}
Expr =
| BlockExpr
| AssignExpr
| VarExpr
| LiteralExpr
| CallExpr
| TupleExpr
| ListExpr
| MapExpr
| StructExpr
| FieldAccessExpr
| IfExpr
| CaseExpr
| WithExpr
| CondExpr
| QuoteExpr
| UnquoteExpr
BlockExpr {
meta: TypedMeta
statements: [Expr]
result: Expr?
}
AssignExpr {
meta: TypedMeta
lhs: Pattern
rhs: Expr
}
VarExpr {
meta: TypedMeta
symbol: SymbolId
resolution: VarResolution
}
VarResolution distinguishes:
- local binding
- local function family
- outer function family
- module function family
- type name
- macro name
CallExpr {
meta: TypedMeta
callee: Expr
args: [Expr]
dispatch: CallDispatch
}
CallDispatch:
CallDispatch =
| DirectFunctionValueCall {
callee_type: TypeId
}
| ScopedFunctionDispatch {
name: SymbolId
tried_scopes: [ScopeDispatchAttempt]
resolved_clause: ResolvedFunctionClauseId
}
ScopeDispatchAttempt:
ScopeDispatchAttempt {
scope_id: ScopeId
family_id: FunctionFamilyId?
result: ScopeDispatchResult
}
ScopeDispatchResult =
| NoFamily
| NoApplicableOverload
| NoMatchingClause
| AmbiguousOverload
| MatchedClause(ResolvedFunctionClauseId)
IfExpr {
meta: TypedMeta
condition: Expr
then_block: BlockExpr
else_block: BlockExpr?
}
CaseExpr {
meta: TypedMeta
scrutinee: Expr
clauses: [CaseClause]
}
CaseClause {
meta: TypedMeta
pattern: Pattern
annotation: TypeExpr?
refinement: Expr?
body: BlockExpr
}
WithExpr {
meta: TypedMeta
items: [WithItem]
body: BlockExpr
else_clauses: [WithElseClause]
}
WithItem =
| WithBind {
meta: TypedMeta
pattern: Pattern
source: Expr
}
| WithExprItem {
meta: TypedMeta
expr: Expr
}
WithElseClause {
meta: TypedMeta
pattern: Pattern
annotation: TypeExpr?
refinement: Expr?
body: BlockExpr
}
CondExpr {
meta: TypedMeta
clauses: [CondClause]
}
CondClause {
meta: TypedMeta
condition: Expr
body: BlockExpr
}
QuoteExpr {
meta: TypedMeta
body: BlockExpr
}
UnquoteExpr {
meta: TypedMeta
expr: Expr
}
TypeExpr =
| TypeNameExpr
| TypeVarExpr
| TypeTupleExpr
| TypeListExpr
| TypeMapExpr
| TypeStructExpr
| TypeUnionExpr
| TypeFunExpr
| TypeLiteralExpr
Nodes:
TypeNameExpr {
meta: NodeMeta
name: SymbolId
args: [TypeExpr]
}
TypeVarExpr {
meta: NodeMeta
name: SymbolId
}
TypeTupleExpr {
meta: NodeMeta
items: [TypeExpr]
}
TypeListExpr {
meta: NodeMeta
item: TypeExpr
}
TypeMapExpr {
meta: NodeMeta
fields: [TypeMapField]
}
TypeStructExpr {
meta: NodeMeta
module: ModuleName
fields: [TypeStructField]
}
TypeUnionExpr {
meta: NodeMeta
members: [TypeExpr]
}
TypeFunExpr {
meta: NodeMeta
params: [TypeExpr]
return_type: TypeExpr
}
TypeLiteralExpr {
meta: NodeMeta
value: Literal
}
Scope {
id: ScopeId
parent: ScopeId?
kind: ScopeKind
bindings: Map<SymbolId, BindingId>
function_families: Map<(SymbolId, Arity), FunctionFamilyId>
macros: Map<(SymbolId, Arity), MacroFamilyId>
imports: [ImportedScope]
aliases: Map<ModuleName, ModuleName>
}
ImportedScope {
source_module: ModuleName
filter: ImportFilter
imported_families: Map<(SymbolId, Arity), FunctionFamilyId>
imported_types: Map<SymbolId, TypeId>
}
ImportFilter =
| ImportAll
| ImportOnly([(SymbolId, Arity?)])
| ImportExcept([(SymbolId, Arity?)])
ScopeKind =
| ModuleScope
| FunctionScope
| BlockScope
| CaseClauseScope
| MacroExpansionScope
| ImportScope
| PreludeScope
FunctionFamily {
id: FunctionFamilyId
scope_id: ScopeId
name: SymbolId
arity: Int
clauses: [ResolvedFunctionClause]
}
ResolvedFunctionClause {
id: ResolvedFunctionClauseId
source_clause: FunctionClauseId
param_types: [TypeId]
return_type: TypeId
refinement_typechecked: Bool
specificity_rank: SpecificityRank
}
To encode fallback chains explicitly:
HIRFunctionGroup {
id: HIRFunctionGroupId
scope_id: ScopeId
name: SymbolId
arity: Int
clauses: [HIRFunctionClause]
fallback_parent: HIRFunctionGroupId?
}
For each function clause:
- build the clause environment from typed patterns
- refine variable types from pattern structure
- typecheck the refinement predicate as
Bool - typecheck the body
- ensure the body result conforms to the declared return type
Patterns refine types branch-locally.
Example:
case value do
{:ok, x} ->
x
{:error, e} ->
handle(e)
endIf value : {:ok, i64} | {:error, String} then:
- first branch binds
x : i64 - second branch binds
e : String
If the callee has function type, check arguments against its parameter types and use its return type.
Run scope-prioritized fallback dispatch and assign the resulting coherent return type.
If overload resolution is ambiguous at a scope layer, compilation fails immediately.
Ambiguity never falls through to outer scopes.
Refinements must:
- typecheck to
Bool - use only the permitted pure subset
- reference only visible bindings
Pattern matching, clause applicability, and fallback dispatch should compile through one unified matcher subsystem.
The matcher compiles to primitives such as:
- literal equality test
- tuple arity test
- list shape test
- struct identity test
- field extraction
- variable bind
- refinement predicate test
- success continuation
- failure continuation
The same matcher subsystem powers:
- function clauses
- local functions
casewith- destructuring assignment
Function dispatch uses matcher failure to continue to the next outer scope family.
That is how scope-prioritized fallback becomes explicit in lowered form.
Use this pipeline:
source
-> lexer
-> layout-sensitive parser
-> surface AST
-> declaration collection
-> macro expansion
-> desugaring
-> name resolution
-> type checking
-> typed HIR
-> dispatch/match IR
-> Zig-shaped IR
-> backend
Responsibilities:
- tokenize
- compute indentation
- emit
NEWLINE,INDENT,DEDENT - attach spans
Responsibilities:
- parse layout-sensitive syntax
- enforce block-only forms
- build source-faithful surface AST
Responsibilities:
- collect types and struct declarations
- collect functions (def and defp)
- collect macros
- process alias and import declarations
- build lexical scopes with import and prelude layers
- hoist local defs within block groups
- form function families
Responsibilities:
- resolve visible macros
- expand hygienically
- repeat to fixed point
- preserve source mappings
Responsibilities:
- desugar string interpolation into
to_stringcalls +<>concatenation - desugar pipe
|>into first-argument insertion - desugar
!into pattern match + panic - normalize operators if desired
- normalize branch forms
- normalize local def groups
- reduce surface variety before typing
Responsibilities:
- resolve symbols
- resolve types
- resolve macro references
- resolve scope-visible function families
Responsibilities:
- assign types
- infer local types
- resolve calls
- refine pattern branches
- validate refinements
- compute captures
- compute effects metadata
Responsibilities:
- turn high-level clauses into explicit decision trees / continuations
- encode fallback chains
Responsibilities:
- represent explicit control flow
- explicit locals
- calls
- closure environments
- ownership/ARC operations
- runtime object operations
Do not lower directly from AST/HIR to Zig internals.
First lower into an IR you own.
The IR should represent:
- constants
- locals
- params
- blocks
- branches
- aggregate init
- field access
- direct calls
- closure calls
- function group dispatch
- closure environment loads
- retain/release
- allocation
- returns
Const
LocalGet
LocalSet
ParamGet
AggregateInit
FieldGet
FieldSet
CallDirect
CallClosure
Branch
CondBranch
SwitchTag
SwitchLiteral
MatchFail
Phi
Return
AllocOwned
Retain
Release
MakeClosure
CaptureGet
This IR is the stable internal lowering contract.
Zig integration sits beneath it.
Local def can capture outer locals.
That requires closure support.
A closure consists of:
- code pointer
- environment pointer
- optional environment metadata
When a local function captures outer bindings:
- generate an environment struct
- populate it at function construction/use site
- pass it to the lowered function
If there are no captures:
- lower to a plain private function
Each local function group becomes a unique internal symbol.
Example:
- module path
- enclosing function path
- local family name
- lexical block ID
The language does not default to a global tracing garbage collector.
Use three tiers.
For:
- ints
- floats
- bools
- enums
- small tuples
- many tagged unions
- stack-local structs
No GC or ARC needed.
For:
- strings
- binaries
- vectors
- maps
- larger runtime records
Managed by explicit ownership and deterministic destruction.
For:
- closures
- shared persistent collection nodes
- shared boxed runtime values if needed
Managed with ARC.
ARC fits:
- closures
- persistent immutable structures
- shared values
without forcing a tracing collector onto all code.
Initial implementation assumes acyclic main-path runtime structures.
If needed later:
- add narrow cycle handling
- or explicit weak/reference-breaking tools
Do not burden v1 with full tracing GC.
The compiler itself should use:
- arenas
- symbol interning
- bulk free per phase
Compiler memory management and runtime memory management are separate concerns.
First emit canonical Zig source.
Reasons:
- correctness oracle
- simpler debugging
- fast bring-up
- inspectable output
After stabilization, add a backend that lowers your IR through a pinned Zig integration layer.
Do not expose Zig internals as your public compiler contract.
Real build performance gains come from architecture.
Implement:
- module signatures
- HIR caches
- macro expansion caches
- codegen-unit reuse
- dependency invalidation
- fallback-chain invalidation
- type errors
- overload ambiguity
- fallback dispatch attempts
- failed pattern matches
- failed refinements
- macro expansion errors
- unreachable clauses
- non-exhaustive matches
- capture-related issues
A failed call should show the actual resolution path.
Example shape:
No matching function for b/1
Tried local scope:
found family b/1
overload b(Int) not applicable to String
Tried module scope:
found family b/1
refinement failed: string_length(s) < 5
No outer scopes remaining
Because whitespace is significant, the formatter should emit one canonical style.
The LSP should support:
- type hover
- go to definition
- overload inspection
- fallback dispatch trace
- macro expansion preview
- capture inspection
defmodule Foo do
type Result(a, e) = {:ok, a} | {:error, e}
def b(s :: String) :: String do
s <> "foo"
end
def a(x :: i64) :: String do
def b(n :: i64) :: String do
int_to_string(n)
end
def b(s :: String) :: String if string_length(s) < 5 do
s <> "bar"
end
b("other")
end
endResolution of b("other") inside a:
- inner
b(i64)exists, not applicable - inner
b(String)is applicable - refinement is evaluated
- if refinement passes, local function is used
- if refinement fails, fallback continues
- module
b(String)is tried - if it matches, module function is used
- otherwise the call fails
That example captures:
- inline types
- local
def - overloading
- refinement predicates
- local-first fallback dispatch
One file defines one module. The module name is derived from the file path relative to the project source root.
lib/my_app/accounts/user.zip → MyApp.Accounts.User
lib/my_app.zip → MyApp
my_project/
zip.toml # project manifest
lib/
my_project.zip # root module
my_project/
accounts.zip # MyProject.Accounts
accounts/
user.zip # MyProject.Accounts.User
test/
my_project/
accounts/
user_test.zip # MyProject.Accounts.UserTest
build/ # compiler output
defdeclares a public functiondefpdeclares a module-private functiontypedeclares a public type (name and representation visible)opaquedeclares a public type name with hidden representation
defmodule MyApp.Accounts.User do
opaque HashedPassword = String
def hash(plain :: String) :: HashedPassword do
do_hash(plain)
end
defp do_hash(plain :: String) :: String do
plain <> "_hashed"
end
endQualified calls always work without import. They resolve directly within the target module's public scope with no fallback.
user = MyApp.Accounts.User.hash("secret")Creates a short name for a fully-qualified module path. Does not bring functions into scope. Purely syntactic convenience.
defmodule MyApp.Main do
alias MyApp.Accounts.User
alias MyApp.Accounts.Session, as: S
# Multi-alias:
alias MyApp.Accounts.{User, Session, Token}
def run() :: String do
User.hash("secret")
end
endAliases are lexically scoped from declaration to end of enclosing block.
Brings a module's public functions and types into the current scope for unqualified access. Imported functions participate in fallback dispatch at the import layer.
defmodule MyApp.Display do
import Formatters.Int, only: [format: 1]
import Formatters.Float, only: [format: 1]
def show(x :: i64) :: String do
format(x) # resolves to Formatters.Int.format
end
endImport forms:
import Module # all public names
import Module, only: [foo: 1, bar: 2] # selective
import Module, except: [debug: 1] # exclusion
import Module, only: [type: MyType] # type importImports are lexically scoped. An import inside a function body is visible only within that function.
If two imports bring conflicting overloads of the same name/arity with the same parameter types, it is an ambiguity error.
For an unqualified call:
- innermost local scope
- enclosing local scopes (fallback outward)
- current module scope
- import scope (all imported families unified)
- prelude scope (auto-imported
Kernel) - no-match error
- The compiler builds a module dependency graph from
importandaliasdeclarations - Modules are compiled in topological order
- Circular dependencies between modules are a compile error
- Module signatures (public types and function signatures) are cached after compilation
- Cross-module type checking uses cached signatures
Macro ordering is resolved automatically by the dependency graph. No explicit require is needed.
use will be supported as macro-powered module injection when the macro system is stable. It is not part of v1.
Structs are declared inside a module using defstruct with a do...end block:
defmodule User do
defstruct do
name :: String
age :: i64
role :: String = "user"
end
end- Every struct belongs to a module
- The module name is the struct type name
- One struct per module
- Fields without a default value are required at construction
- Fields with
= exprhave a default and are optional at construction
%User{name: "Alice", age: 30} # role defaults to "user"
%User{name: "Alice", age: 30, role: "admin"} # override default
%User{age: 30} # COMPILE ERROR: missing required field 'name'Immutable update creates a new struct with selected fields overridden:
older = %User{user | age: 31}
renamed = %User{user | name: "Bob", role: "admin"}The expression before | must have the struct type being constructed. Override fields must exist and have compatible types.
Dot notation for field access:
user.name # => "Alice"
user.age # => 30Compiles to a direct field read. Accessing a nonexistent field is a compile error.
Use %Module{} in type position:
def greet(user :: %User{}) :: String do
"Hello, " <> user.name
endString interpolation uses #{} inside double-quoted strings:
name = "world"
age :: i64 = 42
"Hello #{name}, you are #{age} years old"String interpolation desugars to to_string() calls concatenated with <>:
# "Hello #{name}, you are #{age}"
# desugars to:
"Hello " <> to_string(name) <> ", you are " <> to_string(age)This desugaring happens in the desugaring phase (before type checking).
The prelude (Kernel) provides to_string/1 overloads for all primitive and numeric types:
def to_string(s :: String) :: String # identity
def to_string(n :: i64) :: String
def to_string(f :: f64) :: String
def to_string(b :: Bool) :: String
def to_string(a :: Atom) :: String
def to_string(n :: Nil) :: String
# ... overloads for all numeric typesUsers define to_string overloads for custom types in their modules. Fallback dispatch finds them:
defmodule Point do
defstruct do
x :: f64
y :: f64
end
def to_string(p :: %Point{}) :: String do
"(#{p.x}, #{p.y})"
end
endIf no to_string overload exists for an interpolated type, the compiler emits a standard "no matching function" error.
- Expected failures: Represented by tagged tuples like
{:ok, a} | {:error, e}. These are values. They flow through normal control flow and are pattern-matched. - Unrecoverable panics: Represented by program termination via
panic. For violated invariants and programming errors. Maps to Zig's@panic.
A with expression without an else clause propagates non-matching values directly as the result of the with expression:
def process(input :: String) :: {:ok, Output} | {:error, ParseError} do
with {:ok, parsed} <- parse(input),
{:ok, validated} <- validate(parsed) do
{:ok, validated}
end
# No else: if any step returns {:error, e}, it becomes the result.
# Compiler verifies non-matching types are compatible with the return type.
endThe ! postfix operator unwraps a {:ok, v} value or panics on {:error, e}:
def load_config() :: Config do
parsed = parse(read_file("config.zip"))!
transform(parsed)
endType rule: if expr :: {:ok, a} | {:error, e} then expr! :: a.
If the value is {:error, e}, the program panics with a message including the error value and source location.
panic terminates the program immediately with a message:
def divide(a :: i64, b :: i64) :: i64 do
if b == 0 do
panic("division by zero")
else
a / b
end
endpanic(message :: String) :: NeverNeveris the bottom type — it is a subtype of all types- Maps directly to Zig's
@panic - Automatically includes source location in output
Early return via ? is not part of v1. with covers the chaining case. Can be added later.
There is no exception handling. Recoverable errors use tagged tuples. Unrecoverable errors use panic.
All arithmetic operators require both operands to be the same numeric type. No implicit coercion.
| Operator | Allowed types | Return type |
|---|---|---|
+ |
(T, T) where T is any numeric type |
T |
- |
(T, T) where T is any numeric type |
T |
* |
(T, T) where T is any numeric type |
T |
/ |
(T, T) where T is any integer type |
T (integer division) |
/ |
(T, T) where T is any float type |
T (float division) |
rem |
(T, T) where T is any integer type |
T |
unary - |
any numeric type | same type |
<> works on (String, String) -> String only.
== and != perform structural equality. Both operands must be the same type.
42 == 42 # true
42 == "hello" # COMPILE ERROR: cannot compare i64 with String<, >, <=, >= require same-type operands. Supported for all numeric types and String (lexicographic).
and, or, not are Bool only. No truthiness. Short-circuit evaluation for and and or.
First-argument insertion, desugared before type checking:
x |> f(a, b) # => f(x, a, b)
x |> f() # => f(x)
x |> f # => f(x)
x |> M.f(a) # => M.f(x, a)Left-associative. Desugared in the desugaring phase.
Operators work only on built-in types. Internally, operators desugar to function calls so that a future protocol/typeclass system can extend them.
All numeric type conversions must be explicit. i32 + i64 is a compile error. Use conversion functions from the prelude.
A small fixed set of operations that cannot be expressed as normal functions. Prefixed with @:
@size_of(type)— compile-time type size@type_name(value)— compile-time type name as string@unreachable()— optimization hint / crash@compile_error("msg")— compile-time error from macros
Auto-imported into every module. Occupies the outermost dispatch layer before "not found".
Contents include:
# Type conversions (explicit, for all numeric types)
def i32_to_i64(n :: i32) :: i64
def i64_to_f64(n :: i64) :: f64
def f64_to_i64(f :: f64) :: i64
def i64_to_string(n :: i64) :: String
def f64_to_string(f :: f64) :: String
def string_to_i64(s :: String) :: {:ok, i64} | {:error, String}
def string_to_f64(s :: String) :: {:ok, f64} | {:error, String}
# ... conversion functions for all numeric type pairs
# Arithmetic
def abs(x :: i64) :: i64
def abs(x :: f64) :: f64
def max(a :: i64, b :: i64) :: i64
def min(a :: i64, b :: i64) :: i64
def div(a :: i64, b :: i64) :: i64
# String
def string_length(s :: String) :: i64
# to_string overloads (for string interpolation)
def to_string(s :: String) :: String
def to_string(n :: i64) :: String
def to_string(f :: f64) :: String
def to_string(b :: Bool) :: String
def to_string(a :: Atom) :: String
def to_string(n :: Nil) :: String
# ... overloads for all numeric types
# List
def length(list :: [a]) :: i64
def hd(list :: [a]) :: a
def tl(list :: [a]) :: [a]
# IO
def println(s :: String) :: Nil
def print(s :: String) :: Nil
def inspect(value :: a) :: StringRequire explicit import or qualified access:
List— map, filter, foldl, reverse, sort, zip, flat_map, etc.Map— get, put, delete, keys, values, size, merge, etc.String— slice, contains?, starts_with?, ends_with?, trim, split, replace, upcase, downcase, etc.Enum— generic enumeration functionsMath— sqrt, pow, ceil, floor, etc.IO— file operations, stdin/stdout
The first backend emits canonical Zig source files.
Zap tagged tuples map to Zig's native union(enum):
// Zap: type Expr = {:int, i64} | {:add, Expr, Expr} | {:none}
const Expr = union(enum) {
int: i64,
add: struct { *const Expr, *const Expr },
none: void,
};Atoms with no payload become void variants.
Closures use a fat-pointer representation: function pointer + environment pointer.
const Closure_i64_i64 = struct {
call_fn: *const fn (*anyopaque, i64) i64,
env: *anyopaque,
pub fn invoke(self: @This(), arg: i64) i64 {
return self.call_fn(self.env, arg);
}
};Environment structs are heap-allocated and ARC-managed. One closure type is monomorphized per distinct function signature.
CallDirect— used for statically-known function callsCallClosure— used when calling through a closure value
Reference counting uses atomic operations:
- Retain:
fetchAdd(1, .monotonic) - Release:
fetchSub(1, .release)+fence(.acquire)when count reaches zero
pub const ArcHeader = struct {
ref_count: std.atomic.Value(u32),
pub fn init() ArcHeader {
return .{ .ref_count = std.atomic.Value(u32).init(1) };
}
};The compiler generates type-specific destructors for recursive release chains.
Zap generic types emit as Zig comptime type functions:
// Zap: type Result(a, e) = {:ok, a} | {:error, e}
pub fn Result(comptime A: type, comptime E: type) type {
return union(enum) {
ok: A,
err: E,
};
}| IR Instruction | Emitted Zig |
|---|---|
Const(42) |
const _v0: i64 = 42; |
LocalGet(x) |
x |
LocalSet(x, val) |
const x = <val>; |
AggregateInit(:ok, v) |
.{ .ok = v } |
FieldGet(x, field) |
x.field or via switch capture |
CallDirect(f, args) |
f(arg0, arg1) |
CallClosure(c, args) |
c.invoke(arg0) |
Branch(cond, then, else) |
if (cond) { ... } else { ... } |
SwitchTag(val, cases) |
switch (val) { .tag => |v| { ... } } |
Return(val) |
return val; |
AllocOwned(T, val) |
try Arc(T).init(allocator, val) |
Retain(x) |
x.retain() |
Release(x) |
x.release(allocator) |
MakeClosure(body, captures) |
allocate env struct, fill captures, construct pair |
CaptureGet(idx) |
env.field_name |
A zip_runtime.zig module provides:
Arc(T)— generic ARC wrapperArcHeader— embedded reference countZapAllocator— allocator plumbing- Persistent data structure implementations (vectors via RRB-trees, maps via HAMTs)
StringInterpolationExpr {
meta: TypedMeta
parts: [StringPart]
}
StringPart =
| StringLiteralPart { value: String }
| StringInterpolatedPart { expr: Expr }
Desugared to <> concatenation with to_string calls before type checking.
UnwrapExpr {
meta: TypedMeta
expr: Expr
# expr must be {:ok, a} | {:error, e} type; result type is a
}
PanicExpr {
meta: TypedMeta
message: Expr
# message must be String type; result type is Never
}
StructDecl {
meta: NodeMeta
fields: [StructFieldDecl]
}
StructFieldDecl {
meta: NodeMeta
name: SymbolId
type: TypeExpr
default: Expr?
}
PrivFunctionGroupDecl {
meta: NodeMeta
name: SymbolId
arity: Int
clauses: [FunctionClause]
scope_level: ScopeLevel
visibility: Private
}
AliasDecl {
meta: NodeMeta
module_path: ModuleName
as_name: ModuleName?
}
ImportDecl {
meta: NodeMeta
module_path: ModuleName
filter: ImportFilter?
}
Write and freeze:
- grammar
- type grammar
- numeric type set
- dispatch rules
- macro rules
- memory model
- runtime object model
- module system rules
Implement:
- indentation-sensitive lexer
- layout-aware parser
- string interpolation in lexer
- source spans
- parse tests
- formatter skeleton
Implement:
- module/type/def/defp/defstruct collection
- lexical scope graph with import and prelude layers
- local-def hoisting
- family grouping
- alias resolution
Implement:
- quote/unquote
- hygienic symbol generation
- lexical macro environment
- fixed-point expansion
Implement:
- symbol resolution
- type declarations with all numeric types
- boundary type contracts
- local inference
- generic instantiation
- pattern refinement typing
Nevertype as bottom type
Implement:
- overload applicability
- specificity comparison
- ambiguity detection
- scope fallback dispatch (local → module → import → prelude)
- clause matching
- refinement evaluation
Implement:
- typed HIR
- unified match compilation
- failure continuations
- fallback-parent encoding
Implement either:
- typed-core interpreter
- or lowered-IR verifier
This isolates frontend correctness from backend issues.
Implement:
- canonical Zig emission
- tagged union generation
- closure fat-pointer generation
- ARC retain/release insertion
- generic monomorphization via comptime
zip_runtime.zigsupport module
Implement:
- closures and environment structs
- ARC with atomic operations
- owned containers
- persistent data structures (RRB-tree vectors, HAMT maps)
- runtime context / allocator plumbing
Implement the pinned Zig integration backend.
Implement:
- module signature caches
- HIR caches
- macro expansion caches
- codegen-unit reuse
- dependency invalidation
- fallback-chain invalidation
After the above:
- derive macros
- protocols/typeclasses (enables user-defined operator overloading)
- effect typing if desired
?early-return operatorusedirective- stronger optimizations
Will polymorphism later include:
- protocols
- typeclasses
- trait-like derivation
- none initially
Will effects remain:
- implicit purity-by-default only
- annotation-based
- formalized in types
Will persistent collections be:
- language-defined
- runtime-defined
- standard-library-defined
Will non-module files be first-class or just sugar for an implicit module
This language (Zap) is defined by these hard commitments:
- Elixir-like syntax
- all Zig numeric types exposed directly, no implicit coercion
- inline function-header typing
- no
@spec/@typeprimary typing model - block-only forms
- significant whitespace
def/defpfor module functions,deffor local functionsdefstructfor struct declarations within modules- ordinary function-value call syntax
- ad hoc overloading
- local-first fallback across scopes with import and prelude layers
- pattern matching as a core semantic mechanism
- hygienic macros
- string interpolation via
to_stringoverloads !unwrap-or-panic,panicfor unrecoverable errors,Neverbottom type- one file per module,
aliasandimportfor module system - functional semantics
- native runtime orientation
- hybrid ownership + ARC memory strategy
- compiler-owned IR before Zig integration
- Zig-oriented backend without making Zig internals the language definition