Skip to content

Types & Structs

Sailfin has a rich, statically-checked type system designed to make incorrect programs hard to write. This guide covers everything from the primitive types you saw in Language Basics through algebraic data types, interfaces, generics, and the wrapper types that power Sailfin’s safety guarantees.

These are the building blocks. You have seen them already; the table below adds context for how they sit in the type system.

Type Description Literal examples Notes
int 64-bit integer 0, 42, -7 Default type for integer literals
float 64-bit floating point 3.14, 1.0e9 Default type for decimal literals
number Alias for float 3.14, 1.0e9 Legacy-compatible spelling
boolean Boolean true, false No implicit coercion from number
string UTF-8 text "hello" Immutable; supports {{ }} interpolation
void Return type for functions with no return value

Sized numeric types (i8, i16, i32, i64, u8, u16, u32, u64, f32, f64, usize) exist for FFI interop. In day-to-day Sailfin code, use int for integral values and float for decimal values.

let count: number = 100;
let ratio: number = 0.618;
let active: boolean = true;
let label: string = "pending";

Integer literals default to int; decimal literals default to float.


Structs group related fields into a named product type. They are the primary way to define domain objects in Sailfin.

Fields use the name: Type syntax and end with a semicolon. All fields are immutable by default; prefix a field with mut to allow mutation after construction.

struct Point {
x: number;
y: number;
}
struct Rectangle {
mut width: number;
mut height: number;
}

The same name: Type form is used for struct literals, variables, and parameters. Function return types are the only position that uses ->.

Construct a struct by supplying every field by name:

let origin = Point { x: 0.0, y: 0.0 };
let box = Rectangle { width: 10.0, height: 5.0 };

With a type annotation on the binding:

let corner: Point = Point { x: 3.0, y: 4.0 };

Use dot notation:

let p = Point { x: 1.0, y: 2.0 };
print.info("x = {{p.x}}, y = {{p.y}}");

Only fields declared mut can be reassigned:

let mut r = Rectangle { width: 8.0, height: 4.0 };
r.width = 16.0; // OK — width is `mut`
// r.x = 1.0; // ERROR — no such field; Point fields are also immutable

Methods are declared with fn inside the struct body. The first parameter is the bare identifier self, which receives the struct value:

struct Circle {
radius: number;
fn area(self) -> number {
return 3.14159265 * self.radius * self.radius;
}
fn circumference(self) -> number {
return 2.0 * 3.14159265 * self.radius;
}
fn describe(self) -> string {
return "Circle(r={{self.radius}}, area={{self.area()}})";
}
}
fn main() ![io] {
let c = Circle { radius: 5.0 };
print.info(c.describe());
// Circle(r=5, area=78.5398...)
}

Borrow forms &self / &mut self are not part of the shipped surface today. Ownership and borrowing are on the roadmap.

Static (constructor-style) methods omit self:

struct Color {
r: number;
g: number;
b: number;
fn new(r: number, g: number, b: number) -> Color {
return Color { r: r, g: g, b: b };
}
fn black() -> Color {
return Color { r: 0, g: 0, b: 0 };
}
fn to_hex(self) -> string {
return "#{{self.r}}{{self.g}}{{self.b}}";
}
}
let red = Color.new(255, 0, 0);
let bg = Color.black();

Structs can contain other structs as fields:

struct Address {
street: string;
city: string;
country: string;
}
struct Employee {
id: number;
name: string;
address: Address;
mut salary: number;
}
let emp = Employee {
id: 1001,
name: "Priya Mehta",
address: Address {
street: "12 Oak Lane",
city: "Bangalore",
country: "India",
},
salary: 95000.0,
};
print.info("City: {{emp.address.city}}");

Use the implements keyword in the struct declaration to declare conformance. The compiler verifies that all interface methods are present:

interface Printable {
fn display(self) -> string;
}
struct Invoice implements Printable {
id: string;
amount: number;
paid: boolean;
fn display(self) -> string {
let status = if self.paid { "PAID" } else { "UNPAID" };
return "Invoice {{self.id}}: ${{self.amount}} [{{status}}]";
}
}
fn show(item: Printable) ![io] {
print.info(item.display());
}
fn main() ![io] {
let inv = Invoice { id: "INV-007", amount: 1250.0, paid: false };
show(inv);
// Invoice INV-007: $1250 [UNPAID]
}

Enums in Sailfin are full algebraic data types. Variants can be plain unit values, or they can carry named fields.

enum Direction {
North,
South,
East,
West,
}
let heading: Direction = Direction.North;

Match on an enum with exhaustive arms:

fn describe_direction(d: Direction) -> string {
match d {
Direction.North => return "heading north",
Direction.South => return "heading south",
Direction.East => return "heading east",
Direction.West => return "heading west",
}
}

Variants can carry named fields. This is the mechanism for algebraic data types (“tagged unions”):

enum Shape {
Circle { radius: number },
Rectangle { width: number, height: number },
Triangle { base: number, height: number },
}
fn area(shape: Shape) -> number {
match shape {
Shape.Circle { radius } =>
return 3.14159265 * radius * radius,
Shape.Rectangle { width, height } =>
return width * height,
Shape.Triangle { base, height } =>
return 0.5 * base * height,
}
}
fn main() ![io] {
let s = Shape.Circle { radius: 7.0 };
print.info("Area: {{area(s)}}");
}

Today, nullable values use the T? optional syntax (see Optional Types). A dedicated Option<T> / Some / None type is on the roadmap; until it ships, use T? and null checks:

fn find_user(id: number, users: User[]) -> User? {
for user in users {
if user.id == id {
return user;
}
}
return null;
}
fn main() ![io] {
let found = find_user(42, users);
if found == null {
print.info("User not found");
} else {
print.info("Found: {{found.name}}");
}
}

The prelude Result<T, E> type and postfix ? operator ship today. Use them for expected failures that callers may propagate or inspect; explicit union returns remain available when they better describe the outcome set:

struct ParseError {
message: string;
position: number;
}
fn parse_port(s: string) -> number | ParseError {
let n = number.parse(s);
if n == null {
return ParseError { message: "not a number", position: 0 };
}
if n < 1 || n > 65535 {
return ParseError { message: "port out of range", position: 0 };
}
return n;
}
fn main() ![io] {
match parse_port("8080") {
ParseError { message, position: _ } => print.info("Bad port: {{message}}"),
value => print.info("Listening on port {{value}}"),
}
}

Interfaces define a contract: a set of method signatures that a type promises to satisfy. Any struct that implements all the methods conforms to the interface — there is no separate registration step beyond the implements clause.

interface Serializable {
fn serialize(self) -> string;
fn byte_size(self) -> number;
}
struct Config implements Serializable {
host: string;
port: number;
debug: boolean;
fn serialize(self) -> string {
return "{{self.host}}:{{self.port}} debug={{self.debug}}";
}
fn byte_size(self) -> number {
return self.serialize().length;
}
}

Accepting an interface type enables polymorphism — the caller can pass any conforming struct:

interface Driveable {
fn drive(self) -> string;
fn fuel_type(self) -> string;
}
struct Car implements Driveable {
brand: string;
fn drive(self) -> string {
return "Driving {{self.brand}}";
}
fn fuel_type(self) -> string {
return "petrol";
}
}
struct ElectricBike implements Driveable {
brand: string;
fn drive(self) -> string {
return "Riding {{self.brand}}";
}
fn fuel_type(self) -> string {
return "electric";
}
}
fn start_journey(vehicle: Driveable) ![io] {
print.info(vehicle.drive());
print.info("Fuel: {{vehicle.fuel_type()}}");
}
fn main() ![io] {
start_journey(Car { brand: "Subaru" });
start_journey(ElectricBike { brand: "Cowboy" });
}

A struct can implement any number of interfaces:

interface Named {
fn name(self) -> string;
}
interface Validated {
fn is_valid(self) -> boolean;
}
interface Auditable {
fn audit_log(self) -> string;
}
struct User implements Named, Validated, Auditable {
id: number;
username: string;
email: string;
fn name(self) -> string {
return self.username;
}
fn is_valid(self) -> boolean {
return self.username.length > 0 && self.email.length > 0;
}
fn audit_log(self) -> string {
return "User({{self.id}}, {{self.username}})";
}
}

How Sailfin interfaces compare to Go and Rust

Section titled “How Sailfin interfaces compare to Go and Rust”

Sailfin interfaces are explicit, like Rust traits, rather than structural, like Go interfaces. A type must declare implements SomeInterface to conform — the compiler will not silently satisfy an interface just because the method signatures happen to match. This makes conformance relationships visible in the source code and enables better error messages.

Unlike Rust traits, Sailfin does not use impl Trait for Type as a separate top-level declaration — conformance is always declared inline on the struct. Interface-bounded generic constraints (for example, <T: Comparable>) ship for pointer-width type arguments; arbitrary-width by-value aggregates and general call-site inference remain incomplete.


Generic types and functions are parameterised over types. The compiler captures type parameters and uses them for type checking and code generation.

fn identity<T>(x: T) -> T {
return x;
}
fn first<T>(items: T[]) -> T? {
if items.length == 0 {
return null;
}
return items[0];
}
struct Pair<A, B> {
first: A;
second: B;
fn swap(self) -> Pair<B, A> {
return Pair { first: self.second, second: self.first };
}
}
let coords = Pair { first: 3.0, second: 4.0 };
let flipped = coords.swap();
print.info("{{flipped.first}}, {{flipped.second}}");
// 4, 3
struct Stack<T> {
mut items: T[];
fn new() -> Stack<T> {
return Stack { items: [] };
}
fn push(self, value: T) {
self.items.push(value);
}
fn pop(self) -> T? {
if self.items.length == 0 {
return null;
}
return self.items.pop();
}
fn peek(self) -> T? {
if self.items.length == 0 {
return null;
}
return self.items[self.items.length - 1];
}
fn is_empty(self) -> boolean {
return self.items.length == 0;
}
fn size(self) -> number {
return self.items.length;
}
}
fn main() ![io] {
let mut s = Stack.new();
s.push(10);
s.push(20);
s.push(30);
print.info("Top: {{s.peek()}}"); // 30
print.info("Pop: {{s.pop()}}"); // 30
print.info("Size: {{s.size()}}"); // 2
}

An interface can be generic and declare methods that mention the type parameter:

interface Comparable<T> {
fn compare_to(self, other: T) -> number; // negative, zero, or positive
}

Current scope. Interface-bounded generic constraints (for example, struct SortedList<T: Comparable<T>>) ship for pointer-width type arguments. Arbitrary-width by-value aggregates and general call-site inference remain incomplete; add explicit annotations or type arguments where inference lacks enough context.

Enum variants carry named fields. Result<T, E> ships in the prelude; a first-class Option<T> / Some / None type remains planned. For absence, use the T? optional form shown in Optional Types, or define your own generic enum:

enum Maybe<T> {
Present { value: T },
Absent,
}

Matching unwraps the payload:

fn safe_divide(a: number, b: number) -> Maybe<number> {
if b == 0.0 {
return Maybe.Absent;
}
return Maybe.Present { value: a / b };
}
fn main() ![io] {
match safe_divide(10.0, 3.0) {
Maybe.Present { value } => print.info("Result: {{value}}"),
Maybe.Absent => print.info("Division by zero"),
}
}

Use type to give a name to an existing type. Aliases are transparent — the compiler treats UserId and string as the same type.

type UserId = string;
type Timestamp = number;
type Matrix = number[][];
type Callback = fn(string) -> boolean;

Generic type aliases:

type Table<K, V> = Pair<K, V>[];
type Predicate<T> = fn(T) -> boolean;
type Transformer<A, B> = fn(A) -> B;

Aliases are useful for making signatures self-documenting without the overhead of a newtype:

type OrderId = string;
type CustomerId = string;
fn get_order(order_id: OrderId, customer_id: CustomerId) -> Order ![io] {
return db.find_order(order_id, customer_id);
}

The postfix ? operator creates a nullable type. T? is shorthand for “either a T or null”.

let name: string? = null;
let count: number? = 42;

Optional fields in structs:

struct Profile {
username: string;
bio: string?;
avatar_url: string?;
}
let p = Profile {
username: "ada",
bio: null,
avatar_url: "https://cdn.example.com/ada.png",
};

Sailfin does not allow using an optional value where a concrete value is required without first checking or unwrapping. Guard with if:

fn greet(name: string?) ![io] {
if name == null {
print.info("Hello, stranger!");
} else {
print.info("Hello, {{name}}!");
}
}

Pattern matching on optionals:

fn show_bio(profile: Profile) ![io] {
match profile.bio {
null => print.info("No bio set."),
bio => print.info("Bio: {{bio}}"),
}
}

Optional fields enable recursive data structures. A binary tree node where child pointers may be absent:

struct TreeNode {
value: number;
left: TreeNode?;
right: TreeNode?;
}
fn sum_tree(node: TreeNode?) -> number {
if node == null {
return 0;
}
return node.value + sum_tree(node.left) + sum_tree(node.right);
}

A union type A | B represents a value that can be either A or B. Union types are particularly useful for error returns and heterogeneous collections.

struct NotFoundError {
message: string;
}
struct PermissionError {
required_role: string;
}
fn load_document(path: string) -> string | NotFoundError | PermissionError ![io] {
if !fs.exists(path) {
return NotFoundError { message: "No file at {{path}}" };
}
if !current_user.can_read(path) {
return PermissionError { required_role: "reader" };
}
return fs.read(path);
}

Match on union types by shape — the compiler picks the arm whose type the value matches:

fn handle_load(path: string) ![io] {
let result = load_document(path);
match result {
NotFoundError { message } => print.info("Not found: {{message}}"),
PermissionError { required_role } => print.info("Need role: {{required_role}}"),
content => print.info("Content: {{content}}"),
}
}

You can also use union types to accept heterogeneous inputs:

fn stringify(value: number | boolean | string) -> string {
match value {
number => return "number:{{value}}",
boolean => return "bool:{{value}}",
string => return value,
}
}

Sailfin has four special wrapper types for safety-critical code. Single-use rules ship for Affine<T> and Linear<T>; taint-flow rules for PII<T> and Secret<T> remain deferred.

Status: Owned and affine bindings are move-checked (E0901/E0904), and an unconsumed Linear<T> value is rejected at scope exit (E0907). PII<T> and Secret<T> are parsed, but their taint-flow policies are not enforced yet.

An affine value can be used zero or one times. You can drop it without using it, but you cannot copy or clone it. The intended use case is resources like file handles, connections, or any value where duplication would be a bug:

fn open_file(path: string) -> Affine<FileHandle> ![io] {
return fs.open(path);
}
fn process(handle: Affine<FileHandle>) ![io] {
let data = handle.read_all();
// handle is consumed here — cannot be used again
print.info("Read {{data.length}} bytes");
}
// These are compile errors today:
// fn bad(handle: Affine<FileHandle>) {
// let copy = handle; // ERROR: affine values cannot be copied
// process(handle); // ERROR: already moved
// }

Linear<T> — must be consumed exactly once

Section titled “Linear<T> — must be consumed exactly once”

A linear value is stricter than affine: it cannot be dropped silently. The compiler requires that every linear value is consumed (passed to a function, returned, or explicitly discarded with a consuming operation) before the owning scope exits:

fn mint_auth_token(user_id: number) -> Linear<AuthToken> ![net] {
return auth.issue(user_id);
}
fn use_token(token: Linear<AuthToken>) ![net] {
// Token is consumed by this call — OK
api.authenticate(token);
}
// Compile error today:
// fn forget_token(user_id: number) ![net] {
// let token = mint_auth_token(user_id);
// // ERROR: linear value `token` must be consumed before scope exits
// }

PII<T> — personally identifiable information

Section titled “PII<T> — personally identifiable information”

PII<T> marks data that is subject to privacy policy. Intended enforcement will prevent passing PII values to net or model effects without an explicit redaction step:

struct UserRecord {
id: number;
email: PII<string>;
name: PII<string>;
}
fn render_invoice(user: UserRecord) -> string {
// Intended: accessing PII fields in a net/model context without
// redact() would be a compile error once taint enforcement is active.
let safe_name = redact(user.name);
return "Invoice for {{safe_name}}";
}

Secret<T> — credentials and sensitive tokens

Section titled “Secret<T> — credentials and sensitive tokens”

Secret<T> marks cryptographic material, API keys, passwords, and similar secrets. Intended enforcement prevents secrets from flowing into logs, serialization, or any io operation that would expose them:

fn connect(host: string, api_key: Secret<string>) ![net] {
// Intended: api_key cannot appear in print.info() or log statements
// once Secret enforcement is active.
http.connect_with_key(host, api_key);
}

match is Sailfin’s primary tool for destructuring and branching on complex types. This section ties together everything above.

struct Point {
x: number;
y: number;
}
fn classify(p: Point) -> string {
match p {
Point { x: 0.0, y: 0.0 } => return "origin",
Point { x: 0.0, y } => return "on y-axis at {{y}}",
Point { x, y: 0.0 } => return "on x-axis at {{x}}",
Point { x, y } => return "({{x}}, {{y}})",
}
}
enum Event {
Click { x: number, y: number },
KeyPress { key: string, shift: boolean },
Resize { width: number, height: number },
Quit,
}
fn handle(event: Event) ![io] {
match event {
Event.Click { x, y } => print.info("Click at {{x}},{{y}}"),
Event.KeyPress { key, shift: true } => print.info("Shift+{{key}}"),
Event.KeyPress { key, shift: _ } => print.info("Key: {{key}}"),
Event.Resize { width, height } => print.info("Resize to {{width}}x{{height}}"),
Event.Quit => print.info("Quitting"),
}
}

Add a boolean guard with if after the pattern:

fn describe_number(n: number) -> string {
match n {
0 => return "zero",
n if n < 0 => return "negative ({{n}})",
n if n % 2 == 0 => return "even positive ({{n}})",
n => return "odd positive ({{n}})",
}
}

Patterns can be nested to any depth:

enum Expr {
Lit { value: number },
Add { left: Expr, right: Expr },
Mul { left: Expr, right: Expr },
Neg { expr: Expr },
}
fn eval(e: Expr) -> number {
match e {
Expr.Lit { value } => return value,
Expr.Add { left, right } => return eval(left) + eval(right),
Expr.Mul { left, right } => return eval(left) * eval(right),
Expr.Neg { expr: Expr.Lit { value } } => return -value,
Expr.Neg { expr } => return -eval(expr),
}
}

Roadmap note. A dedicated pattern as name binding form is on the roadmap. Today, bind the whole value first and then match on it:

fn log_event(event: Event) ![io] {
match event {
Event.Click { x, y } => {
print.info("Logging click at {{x}},{{y}}");
audit_log(event);
},
_ => { },
}
}

Use _ to ignore a value, or a plain identifier to bind-and-ignore the rest:

struct NotFound {
message: string;
}
fn is_error(result: number | NotFound) -> boolean {
match result {
NotFound { message: _ } => return true,
_ => return false,
}
}

The compiler checks that match arms cover all possible cases. For enums, every variant must appear (or a wildcard _ must be present). Missing arms produce a compile error:

error[E0210]: non-exhaustive match on `Direction`
--> src/main.sfn:8:5
|
8 | match d {
| ^^^^^^^^ missing arm for `Direction.East`, `Direction.West`
|
= help: add a `_` wildcard arm or cover the missing variants

Sailfin infers types from context where possible. The rules are straightforward:

  • Variable initializers: let x = 42 infers int; let x = 4.2 infers float.
  • Return types: if all return statements return the same type, the compiler can infer the return type (annotation still recommended for public APIs).
  • Array literals: let nums = [1, 2, 3] infers int[].
  • Struct literal fields: field types are checked against the struct declaration.
  • Closure parameters: items.map(fn(x) => x * 2) infers x: int from the element type of items.
let score = 95; // int
let ratio = score as float / 100.0; // float
let passing = score >= 60; // boolean
let label = if passing { "pass" } else { "fail" }; // string
  • Function parameters: always require explicit types.
  • Ambiguous generics: let empty = [] — the element type is unknown; write let empty: number[] = [].
  • Interface types: let v: Driveable = Car { ... } — the concrete type must be coerced to the interface type explicitly via the annotation.
  • Recursive functions: the return type annotation is required when the function calls itself.
// REQUIRED: parameter types cannot be inferred
fn multiply(a: number, b: number) -> number {
return a * b;
}
// REQUIRED: empty collection needs annotation
let empty: string[] = [];
// REQUIRED: return type needed for recursive fn
fn factorial(n: number) -> number {
if n <= 1 { return 1; }
return n * factorial(n - 1);
}

Type inference coverage is partial. The compiler performs inference on straightforward initializers and return types but does not yet perform full Hindley-Milner unification. When the compiler cannot determine a type, it will ask you to add an annotation:

error[E0100]: type annotation required
--> src/lib.sfn:14:9
|
14 | let result = transform(items);
| ^^^^^^ cannot infer type — add an annotation: `let result: T[] = ...`