Sakura Lang

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

TypeValuesNotes
Unit()Result of statement-only functions.
Booltrue, false&& and || short-circuit.
Int64-bit signedChecked: 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.
FloatIEEE-754 binary64NaN != NaN. Not Hash, so not a Map key.
Stringimmutable UTF-8 textNo integer indexing. String.len is the byte length; String.chars and String.graphemes give lists.
Bytesimmutable bytesBytes.get(b, i) -> Option<Int>.
NevernoneType 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> and Set<T> (keys need Hash, iteration is insertion order), and Range (0..n, exclusive end).
  • Functions: fn(A, B) -> C ! row. Without ! the function is pure.
  • Handlers: Handler<Eff, r>, a handler for Eff whose clauses perform r. See Handlers.
  • External types: a type declared without a body, such as pub type Scope, has a host representation, no constructors, and no derived constraints. Only @external functions produce its values.
  • Resources: Fs.File, Sql.Conn, Http.Body, Task.Fiber<T>. External types that are affine and scope-bound; see Memory model. Dir is an external capability type. Network attenuation uses Http.scoped(hosts), not NetScope.
  • Secret<T> and Untrusted<T>: see Capabilities.
  • Graph<N> with NodeId keys, 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) > 0

The 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 defect D010 on failure. Use it at trusted boundaries only; the planned sakura audit will list each use.
  • Schema decoding (Json.decode, Request.json, Llm.generate), which checks nested refinements and reports a typed DecodeError, never a defect.
  • Literals: let p: Percent = 50 is checked at compile time where possible, otherwise at program start (warning V5002).

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.

ConstraintDerived forNot derived for
Eqall data types, recursivelyfunctions, handlers, resources, Secret
OrdInt, Float (NaN last), String, Bytes, and tuples, records, variants of Ord parts (declaration order)Map, Set, functions, Secret
HashEq types except Float, Map, Setas Eq, plus Float
Showall data types; records print as User { id: 1, email: "a@b" }functions, handlers, resources, Secret
Schemadata 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):

ValueShows as
Int, Bool, Unit42, true, ()
Floatalways with . or an exponent: 2.0, 0.1, 1.0e21, -0.0, NaN, Infinity
Stringraw at the top level of an interpolation, quoted with escapes inside any container
tuple, List(1, "a"), (1,), [1, 2]
Map, Set, BytesMap.of([("a", 1)]), Set.of([1]), Bytes.of([1, 2])
recordUser { id: 1, email: "a@b" }
variantunqualified constructor: None, Some("x"), Warn, String("x")
error tagForbidden, 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 fn type 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 let constants, property parameters, 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 type

A 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):

  1. e is a record and f is a field: field access.
  2. e names an effect: Eff.op(args) performs the operation on the innermost installed handler. If op is not an operation, Eff.f is a function of the effect's home module, which is how handler constructors such as Console.host(), Clock.fixed(t), and Users.memory([]) are found.
  3. e names a type: Type.f is function f in the type's home module (the module that declared it; for compiler-known types, std/<lowercase name>, so List.map is std/list.map). Type.Name is a variant of the type or a type declared in its home module: Json.String(s), Level.Warn, Task.Fiber<T>.
  4. e names an imported module: mod.f is that module's exported f.
  5. Otherwise e is a value of type T, and e.f(args) means T.f(e, args) with f looked up in T'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), constructors Some(x), records User { id, .. }, tuples (a, b), lists [], [x, ..rest], [first, second, ..], and or-patterns A | B.
  • Literal patterns on Int, String, or Float need 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 Int and Float never 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: validate returns a Result, from defects (D010), decoding returns DecodeError. 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.

Edit on GitHub

On this page