Skip to content

Type System

Skein has a set of built-in primitive and parameterized types. Type annotations are required on all function parameters and return types.

TypeSkeinCore Erlang / BEAM Representation
IntInteger valuesErlang integer
FloatFloating-point valuesErlang float
StringUTF-8 textErlang binary
Booltrue / falseErlang atoms true / false
UuidUUID stringsErlang binary
InstantTimestampsErlang binary
DurationTime durationsErlang binary
EmailEmail addressesErlang binary
UrlURL stringsErlang binary
TypeDescription
Option[T]A value that may be absent
Result[T, E]A value or an error
List[T]Ordered collection
Map[K, V]Key-value mapping
Set[T]Unique collection

Parameterized types use bracket syntax:

Option[String]
Result[User, DbError]
List[Int]
Map[String, User]

These parse into %AST.TypeRef{} nodes with populated params lists.

Functions require explicit type annotations on all parameters and the return type:

fn add(a: Int, b: Int) -> Int {
a + b
}
fn greet(name: String) -> String {
"Hello, ${name}!"
}
fn is_positive(n: Int) -> Bool {
n > 0
}

These annotations are stored in the AST as %AST.TypeRef{} nodes:

%AST.TypeRef{name: "Int", params: [], meta: %{line: 1, col: 15, file: "example.skein"}}

The analyzer’s type checking pass (Pass 2) validates types across function boundaries and expressions.

  • Function return types: The type of the body expression must match the declared return type
  • Operator types: Arithmetic operators require numeric operands, comparison operators produce Bool
  • Match arm consistency: All arms of a match expression must produce the same type
  • Function call arity: The number of arguments must match the function’s parameter count
  • Let binding inference: Types of local bindings are inferred from their initializer expressions

When a type mismatch is detected, the analyzer produces a structured error:

{
"code": "E0020",
"severity": "error",
"message": "Type mismatch: expected String, got Int",
"location": {"file": "example.skein", "line": 5, "col": 3},
"fix_hint": "Check that the expression matches the expected type"
}
CodeDescription
E0010Undefined identifier
E0011Duplicate definition
E0012Missing capability declaration
E0020Type mismatch — including operator type errors and wrong argument counts for fn, stdlib, and effect calls
E0021Non-exhaustive match on a boolean (error)
E0024Unknown type name (error); non-exhaustive match on an enum, Result, or Option (error)
E0025Invalid constraint annotation

A match on a closed type (Bool, enums, Result, Option) must cover every case. A missing arm is a compile error, not a warning — this code does not compile:

fn check(x: Bool) -> String {
match x {
true -> "yes"
-- error E0021: Non-exhaustive match: missing pattern 'false'
}
}

Add a false -> ... arm or a wildcard _ pattern to make it compile.

Record types define structured data:

type User {
id: Uuid
email: String
name: String
}

Enum types define variants with optional associated data:

enum Status {
Active
Suspended(reason: String)
Deleted
}

Type fields support constraint annotations that flow through to generated JSON schemas:

type Money {
amount: Int @min(0)
currency: String @one_of(["USD", "CAD", "EUR"])
}
type Config {
max_retries: Int @min(1) @max(10) @default(3)
}
AnnotationApplies toJSON Schema
@min(n)Int, Float"minimum": n
@max(n)Int, Float"maximum": n
@one_of([...])String"enum": [...]
@default(v)Any"default": v
@primaryField— (storage: primary key)
@uniqueField— (storage: unique index)
@description(s)Field, Type"description": s

Type declarations automatically generate JSON schemas via Skein.CodeGen.SchemaGen:

Skein.CodeGen.SchemaGen.to_json_schema(%AST.TypeDecl{
name: "User",
fields: [
%AST.Field{name: "id", type: %AST.TypeRef{name: "Uuid", params: []}, annotations: []},
%AST.Field{name: "email", type: %AST.TypeRef{name: "String", params: []}, annotations: []},
%AST.Field{name: "name", type: %AST.TypeRef{name: "String", params: []}, annotations: []}
]
})

Produces:

{
"type": "object",
"properties": {
"id": {"type": "string", "format": "uuid"},
"email": {"type": "string"},
"name": {"type": "string"}
},
"required": ["email", "id", "name"]
}
Skein TypeJSON Schema
String{"type": "string"}
Int{"type": "integer"}
Float{"type": "number"}
Bool{"type": "boolean"}
Uuid{"type": "string", "format": "uuid"}
Email{"type": "string", "format": "email"}
Url{"type": "string", "format": "uri"}
Instant{"type": "string", "format": "date-time"}
Duration{"type": "string"}
Option[T]Schema for T (not required)
List[T]{"type": "array", "items": <T>}
Set[T]{"type": "array", "uniqueItems": true, "items": <T>}
Map[K, V]{"type": "object", "additionalProperties": <V>}
# Simple type
%AST.TypeRef{name: "String", params: []}
# Parameterized type
%AST.TypeRef{
name: "Result",
params: [
%AST.TypeRef{name: "User", params: []},
%AST.TypeRef{name: "DbError", params: []}
]
}
# Type declaration
%AST.TypeDecl{
name: "User",
fields: [
%AST.Field{name: "id", type: %AST.TypeRef{name: "Uuid"}, annotations: []},
%AST.Field{name: "email", type: %AST.TypeRef{name: "String"}, annotations: []}
]
}

Enum variants with fields compile to tagged tuples at runtime. You can pattern match on them in match expressions:

enum Shape {
Circle(radius: Int)
Rect(width: Int, height: Int)
}
fn area(s: Shape) -> Int {
match s {
Shape.Circle(r) -> r * r
Shape.Rect(w, h) -> w * h
}
}

At runtime, Shape.Circle(5) is represented as the tuple {:circle, 5} and Shape.Rect(3, 4) as {:rect, 3, 4}. Simple variants without fields (like Active) compile to atoms (:active).

The analyzer checks that all enum variants are covered in a match expression (a missing variant is error E0024). A wildcard _ arm satisfies this check.

Exhaustiveness checking operates at the variant level, not at the value level within variant fields. For example:

enum Action {
GetUser(id: Int)
DeleteUser(id: Int)
}
fn handle(a: Action) -> String {
match a {
Action.GetUser(5) -> "found user 5" -- only matches id=5
Action.DeleteUser(id) -> "deleted"
}
}

Variant-level coverage is satisfied because an arm exists for GetUser — but at runtime, Action.GetUser(10) would raise case_clause because the literal 5 does not match 10. The analyzer warns about exactly this (W0004): “Match on Action covers only specific values of GetUser: other GetUser(…) values raise case_clause at runtime”. To clear the warning, use a variable pattern or add a wildcard arm:

fn handle(a: Action) -> String {
match a {
Action.GetUser(id) -> "found user" -- matches all GetUser values
Action.DeleteUser(id) -> "deleted"
}
}

Variant-level coverage is an error-grade check (a missing variant is E0024); value-level coverage inside a variant is warning-grade (W0004) — the match still compiles, but the warning tells you a runtime case_clause is possible.