Types
Primitive types, records, variants, refinements, generics with derived constraints, inference, method-call resolution, and exhaustive pattern matching in Sakura Lang.
Pre-alpha
The source-built compiler runs the core language and much of M3 on JavaScript: contracts, typed effects and failures, std handlers, permissions, LSP and MCP. M3 is still in progress; no release or stable compatibility contract exists. See the roadmap for implemented features and remaining gates.
Sakura Lang is statically typed with Hindley-Milner inference inside bodies. Top-level signatures are always explicit. There are no implicit conversions, no null, and no subtyping except three narrow cases: refined types coerce to their base, smaller error unions fit larger ones, and closed effect rows widen at function-value use sites.
Primitive types
| Type | Values | Notes |
|---|---|---|
Unit | () | Result of statement-only functions. |
Bool | true, false | && and || short-circuit. |
Int | 64-bit signed | Checked: overflow is defect D001, division by zero is D002. / and % truncate toward zero. On the JS backend, values beyond ±(2^53−1) are defect D003, never silently wrong. |
Float | IEEE-754 binary64 | NaN != NaN. Not Hash, so not a Map key. |
String | immutable UTF-8 text | No integer indexing. String.len is the byte length; String.chars and String.graphemes give lists. |
Bytes | immutable bytes | Bytes.get(b, i) -> Option<Int>. |
Never | none | Type of fail e, return e, break, continue. |
Conversions are explicit functions: Int.to_float, Float.round (defect on NaN or out of range), Float.to_int_checked -> Option<Int>, Int.to_string, String.parse_int -> Option<Int>. Parsing never defects: String.parse_int returns None for text that is not a number and for a number the backend cannot represent, which on JavaScript means beyond ±(2^53−1). Decimal (std) is a library type for money with Decimal.add and friends; it has no operators in v0.
Records
Records are nominal. Construction names the type, and a field whose value is a same-named local can be written once (punning). Update copies a record and overrides the listed fields.
type User = { id: Int, email: String, name: String }
test "records" {
let id = 7
let u = User { id, email: "a@b.co", name: "Ada" }
let moved = User { ..u, email: "ada@new.co" }
expect_eq(moved.email, "ada@new.co")
expect_eq(u.email, "a@b.co")
}Every field must be supplied at construction. Every field named in an update must exist. There are no anonymous records in v0. When u is not used again, the native backend can reuse its memory for the update; see Memory model.
Tuples
(A, B) is a tuple type, (1, "one") a tuple value, and .0, .1 its fields. (e) is grouping, (e,) is a one-element tuple, and () is Unit.
Variants
A variant type is a nominal sum. Payloads are positional. Constructors are functions: Circle has type fn(Float) -> Shape.
type Shape = Circle(Float) | Rect(Float, Float) | Empty
fn area(s: Shape) -> Float {
match s {
Circle(r) => 3.14159 * r * r
Rect(w, h) => w * h
Empty => 0.0
}
}Adding a variant makes every match on Shape that does not handle it a compile error (V4001), so the change cannot be half applied.
No null: Option
A value that may be absent has type Option<T> = None | Some(T).
fn find_user(users: List<User>, email: String) -> Option<User> {
List.find(users, fn(u) { u.email == email })
}
fn display_name(users: List<User>, email: String) -> String {
find_user(users, email).map(fn(u) { u.name }).unwrap_or("guest")
}The Option module has map, unwrap_or, is_some, or_fail (turns None into a typed failure), and expect. Option.expect on None is defect D030; use it only where absence is a bug.
Errors and Result
Result<T, E> = Ok(T) | Err(E) exists, but a function that can fail declares Fail<E> in its effect row instead of returning Result. E is a union of nominal error tags.
error ParseError(input: String)
fn parse_age(s: String) -> Int ! {Fail<ParseError>} {
match String.parse_int(s) {
Some(n) if n >= 0 => n
_ => fail ParseError { input: s }
}
}
test "bad age is a typed failure" {
let r = try { parse_age("x") }
expect_eq(r, Err(ParseError { input: "x" }))
}try { body } without catch turns failures into a Result. Postfix r? turns a Result back into Fail<E>. Only error tags can be unioned: type SignupError = EmailTaken | Banned. Recovery with try/catch is covered in Effects.
Other built-in types
- Collections:
List<T>([1, 2, 3]),Map<K, V>andSet<T>(keys needHash, iteration is insertion order), andRange(0..n, exclusive end). - Functions:
fn(A, B) -> C ! row. Without!the function is pure. - Handlers:
Handler<Eff, r>, a handler forEffwhose clauses performr. See Handlers. - External types: a
typedeclared without a body, such aspub type Scope, has a host representation, no constructors, and no derived constraints. Only@externalfunctions produce its values. - Resources:
Fs.File,Sql.Conn,Http.Body,Task.Fiber<T>. External types that are affine and scope-bound; see Memory model.Diris an external capability type. Network attenuation usesHttp.scoped(hosts), notNetScope. Secret<T>andUntrusted<T>: see Capabilities.Graph<N>withNodeIdkeys, for cyclic data; see Memory model.
Refinement types
A refinement narrows a base type with a predicate. it is the value being checked.
type Email = String where String.len(it) <= 254 && String.contains(it, "@")
type Percent = Int where it >= 0 && it <= 100
type NonEmpty<T> = List<T> where List.len(it) > 0The predicate must be in the contract subset: pure, terminating, no loops or recursion (design §7.1). A refined type is a distinct nominal type with the same representation as its base. Values enter it through:
Percent.validate(n) -> Result<Percent, Violation>, generated for every refined type. Use it for external input.Percent.from(n) -> Percent, a checked introduction that is defectD010on failure. Use it at trusted boundaries only; the plannedsakura auditwill list each use.- Schema decoding (
Json.decode,Request.json,Llm.generate), which checks nested refinements and reports a typedDecodeError, never a defect. - Literals:
let p: Percent = 50is checked at compile time where possible, otherwise at program start (warningV5002).
A refined value coerces implicitly to its base: a Percent works anywhere an Int does. The reverse never happens implicitly. Because no value can be mutated, a validated value stays valid. Contracts are covered in Contracts.
Generics and constraints
Generics are rank-1. Uppercase type parameters are type variables; lowercase ones are effect-row variables (Effects).
type Pair<A, B> = { left: A, right: B }
fn swap<A, B>(p: Pair<A, B>) -> Pair<B, A> {
Pair { left: p.right, right: p.left }
}
fn largest<A: Ord>(xs: List<A>) -> Option<A> {
List.last(List.sort(xs))
}
fn render<A: Eq + Show>(xs: List<A>, highlight: A) -> String {
List.join(List.map(xs, fn(x) { if x == highlight { "[{x}]" } else { "{x}" } }), ", ")
}Constraints come from a fixed set that the compiler derives structurally. There are no traits, no interfaces, and no user-written instances in v0.
| Constraint | Derived for | Not derived for |
|---|---|---|
Eq | all data types, recursively | functions, handlers, resources, Secret |
Ord | Int, Float (NaN last), String, Bytes, and tuples, records, variants of Ord parts (declaration order) | Map, Set, functions, Secret |
Hash | Eq types except Float, Map, Set | as Eq, plus Float |
Show | all data types; records print as User { id: 1, email: "a@b" } | functions, handlers, resources, Secret |
Schema | data types made of Schema parts, refinements included (JSON Schema plus codec) | functions, handlers, resources, Secret, Graph |
An unconstrained type parameter can only be passed around and matched through concrete constructors. Each constraint unlocks a fixed set of operations: Eq gives == and !=; Ord gives <, <=, >, >= and implies Eq; Hash makes a type usable as a Map key or Set element; Show allows interpolation and show; Schema allows Json.encode and Json.decode. A concrete type that lacks a required constraint is V2032.
Show output
Interpolation uses Show. Its output is valid Sakura Lang expression syntax, stable across backends, and the format sakura test snapshots use (design §4.8):
| Value | Shows as |
|---|---|
Int, Bool, Unit | 42, true, () |
Float | always with . or an exponent: 2.0, 0.1, 1.0e21, -0.0, NaN, Infinity |
String | raw at the top level of an interpolation, quoted with escapes inside any container |
tuple, List | (1, "a"), (1,), [1, 2] |
Map, Set, Bytes | Map.of([("a", 1)]), Set.of([1]), Bytes.of([1, 2]) |
| record | User { id: 1, email: "a@b" } |
| variant | unqualified constructor: None, Some("x"), Warn, String("x") |
| error tag | Forbidden, NotFound { id: "u1" } |
So "{name}" with name = "Ada" prints Ada, while "{[name]}" prints ["Ada"].
Inference
- Top-level functions are fully annotated and the body is checked against the signature.
let,var, and lambda parameters are inferred. Only syntactic values (lambdas, literals, constructors) are generalized; an effectful expression never is.- A lambda is checked against the expected
fntype when one is known, such as an argument position. - Top-level functions may be mutually recursive. Local lambdas may not recurse (
V1020). - You must also annotate
pub letconstants,propertyparameters, and empty collections whose element type cannot be inferred (V2010). There are no call-site type arguments (V0011); a type-directed function gets its type from a binding annotation:
import std/json.{DecodeError}
fn start() -> List<Int> {
let xs: List<Int> = List.empty() // not List<Int>.empty()
List.push(xs, 1)
}
fn load(text: String) -> User ! {Fail<DecodeError>} {
let u: User = Json.decode(text)? // not Json.decode<User>(text)
u
}Type aliases and distinct types
type Meters = Float is a transparent alias: Meters and Float are the same type. For a distinct type, use a refinement or a single-variant type:
import std/http.{Request, Response}
import std/sql.{Sql}
type Meters = Float // alias
type UserId = UserId(Int) // distinct: UserId(7) is not an Int
type Route = fn(Request) -> Response ! {Sql, Log} // alias for a function typeA variant may share its type's name, as UserId does. Two refined types with identical predicates are still different types. Tuples, functions, lists, maps, sets, options, results, and ranges are structural in their parameters; everything else is nominal.
Opaque types
pub type exports a type together with its constructors and fields. pub opaque type exports only the name: other modules can hold, pass, compare, and show its values, but constructing one or reading its fields outside the declaring module is V1006.
// src/tokens.sakura
pub opaque type Token = Token(String)
pub fn issue(user: Int) -> Token ! {Random} {
Token("{user}-{Random.uuid()}")
}Other modules can only get a Token from tokens.issue. Std uses the same mechanism: Uuid comes only from Uuid.parse or Random.uuid, a SQL Query<R> only from the sql"…" template, and Html only from escaping code. Opaque types still derive Eq, Ord, Hash, Show, and Schema. To validate by predicate rather than by construction, use a refinement type.
Method-call resolution
e.f(args) is resolved in this order; the first match wins (design §4.9):
eis a record andfis a field: field access.enames an effect:Eff.op(args)performs the operation on the innermost installed handler. Ifopis not an operation,Eff.fis a function of the effect's home module, which is how handler constructors such asConsole.host(),Clock.fixed(t), andUsers.memory([])are found.enames a type:Type.fis functionfin the type's home module (the module that declared it; for compiler-known types,std/<lowercase name>, soList.mapisstd/list.map).Type.Nameis a variant of the type or a type declared in its home module:Json.String(s),Level.Warn,Task.Fiber<T>.enames an imported module:mod.fis that module's exportedf.- Otherwise
eis a value of typeT, ande.f(args)meansT.f(e, args)withflooked up inT's home module.
There are no methods, no self, and no extension methods. A function becomes callable with dot syntax on a type only by living in that type's module:
// src/shapes.sakura, the home module of Shape
pub type Shape = Circle(Float) | Rect(Float, Float) | Empty
pub fn area(s: Shape) -> Float {
match s {
Circle(r) => 3.14159 * r * r
Rect(w, h) => w * h
Empty => 0.0
}
}// src/report.sakura, in package `app`
import app/shapes.{Shape}
fn total_area(xs: List<Shape>) -> Float {
List.fold(xs, 0.0, fn(acc, s) { acc + s.area() }) // s.area() is Shape.area(s)
}On a generic A with no constraint, x.len() is an error with a fix suggesting a constraint; there is no structural lookup.
Pattern matching
match must be exhaustive. A missing case is V4001 with a witness value; a redundant arm is V4002. Guards never count toward coverage.
fn summary(xs: List<Int>) -> String {
match xs {
[] => "empty"
[x] => "one item: {x}"
[x, y] => "two items: {x}, {y}"
[first, ..rest] => "{first} and {List.len(rest)} more"
}
}
fn size(n: Int) -> String {
match n {
0 => "none"
1 | 2 | 3 => "a few"
_ if n < 0 => "negative"
_ => "many"
}
}
fn is_admin(u: User) -> Bool {
match u {
User { name: "root", .. } => true
_ => false
}
}- Patterns:
_, names, literals (including-5), constructorsSome(x), recordsUser { id, .. }, tuples(a, b), lists[],[x, ..rest],[first, second, ..], and or-patternsA | B. - Literal patterns on
Int,String, orFloatneed a_arm. - Record patterns need
..to ignore remaining fields. - All alternatives of an or-pattern must bind the same names with the same types (
V4003). - Matching on an error union is exhaustive over its tags.
Tagged templates
A tagged string tag"…" desugars to tag.template(parts, values), where tag resolves to a module that exports template. Each interpolated value is checked against the tag's argument type, and the tag receives it as a value, never as spliced text.
import std/sql.{Sql, SqlError}
fn find_by_email(email: Email) -> List<User> ! {Sql, Fail<SqlError>} {
Sql.query(sql"select id, email, name from users where email = {email}")
}Here {email} becomes a bound parameter. For sql, an Untrusted<String> or a refined string converts to SqlValue, but a raw table name does not. html"<p>{name}</p>" escapes text by construction. Because Query<R> and Html are opaque types, a template is the only way to build one. The prelude tags are sql, html, url, and shell. See Capabilities.
What the compiler checks
- Every expression has one type. No implicit conversions, and
IntandFloatnever mix. - Matches are exhaustive (
V4001) with no redundant arms (V4002). - There is no null and no uninitialized value. Absence is
Option. - Record construction supplies every field.
- Refined values are checked at introduction:
validatereturns aResult,fromdefects (D010), decoding returnsDecodeError. They are never silently trusted. - Constraints are derived, never declared, so
Secret, functions, and resources can never be shown, compared, or serialized by accident.
Row parameters on types
Types can carry effect rows as well as ordinary type arguments: Tool<e>, Agent<T, e>, and Router<e>. A concrete row is written Tool<{Http}>; a lowercase e denotes a row parameter, not a type parameter. These parameters preserve callback effects through records and collections. Access to opaque constructors and fields outside the defining module is rejected with V1006.
Next
Effects: how Sakura Lang puts every side effect and error in the signature.