Type System
Dovetail has a powerful type system that catches errors at compile time while keeping your code clean through type inference.
6.1 Type Inference
Section titled “6.1 Type Inference”Dovetail infers types whenever possible, so you don’t need to write them explicitly:
let name = "Alice" // inferred as Stringlet age = 30 // inferred as Int32let price = 19.99 // inferred as Float64let active = true // inferred as BoolYou can add explicit type annotations when needed:
let count: Int64 = 100 // explicitly Int64, not Int32let ratio: Float32 = 0.5 // explicitly Float32, not Float64Function return types must be explicit (for readability), except for Unit:
function double(x: Int32): Int32 = x * 2
function calculateTotal(price: Float64, quantity: Int32): Float64 = price * quantity.toFloat64()
// Unit return type can be omittedfunction greet(name: String) = debug("Hello, $name!")6.2 Records
Section titled “6.2 Records”Records are data-only types with named fields. They’re similar to structs in other languages.
Defining Records
Section titled “Defining Records”record Point = x: Int32 y: Int32
record User = name: String email: String age: Int32Creating Records
Section titled “Creating Records”Use the record name followed by field initializers in braces:
let origin = Point { x = 0; y = 0 }let user = User { name = "Alice"; email = "alice@example.com"; age = 30 }Multi-line syntax:
let user = User { name = "Alice" email = "alice@example.com" age = 30}Accessing Fields
Section titled “Accessing Fields”Use dot notation:
let userName = user.namelet userAge = user.ageUpdating Records with with
Section titled “Updating Records with with”Records are immutable. Use with to create a modified copy:
let older = user with { age = user.age + 1 }let renamed = user with { name = "Bob" }Private Construction
Section titled “Private Construction”Put private after the record name to reserve construction and with updates for its associated module:
public record Account private = balance: Int32
module Account = public function make(balance: Int32): Result<Account, String> = if balance < 0 then Error("balance must not be negative") else Ok(Account { balance = balance })
public function deposit(self, amount: Int32): Result<Account, String> = if amount < 0 then Error("deposit must not be negative") else Ok(self with { balance = self.balance + amount })Any caller that can see Account can read account.balance and destructure an account in a pattern. Only module Account in the defining package can write Account { ... } or account with .... Sharing the package or file is not enough; trait implementations and extensions must call the module’s functions too.
Leading public controls visibility of the type itself. For generic records, write record Box<T> private where T: Equatable = ...: the construction modifier follows type parameters and precedes constraints. Empty private records use record Token private, with the modifier on the same line as the name.
Records are Equatable
Section titled “Records are Equatable”Records with equatable fields can opt into equality with @derive(Equatable)
on the record declaration:
@derive(Equatable)record Point = x: Int32 y: Int32
let p1 = Point { x = 1; y = 2 }let p2 = Point { x = 1; y = 2 }p1 == p2 // true6.3 Enums (Discriminated Unions)
Section titled “6.3 Enums (Discriminated Unions)”Enums represent values that can be one of several variants. Each variant can optionally carry data.
Simple Enums
Section titled “Simple Enums”enum Color = Red Green Blue
let color = RedEnums with Data
Section titled “Enums with Data”Variants can carry payload data:
enum Shape = Circle(Float64) // radius Rectangle(Float64, Float64) // width, height Point
let circle = Circle(5.0)let rect = Rectangle(10.0, 20.0)let point = PointPattern Matching on Enums
Section titled “Pattern Matching on Enums”Use match to handle different variants:
function area(shape: Shape): Float64 = match shape with case Circle(radius) => 3.14159 * radius * radius case Rectangle(w, h) => w * h case Point => 0.0Named Record Payloads
Section titled “Named Record Payloads”A variant with exactly one record payload supports both positional and record syntax:
record Placement = placedAt: Int32 total: Int32
enum OrderStatus = Draft Placed(Placement)
let placement = Placement { placedAt = 1; total = 100 }let first = OrderStatus.Placed(placement)let second = OrderStatus.Placed { placedAt = 1; total = 100 }The brace construction is shorthand for OrderStatus.Placed(Placement { placedAt = 1; total = 100 }). The payload remains a Placement value that can be passed around independently.
Patterns can bind that whole value or inspect its fields:
function total(status: OrderStatus): Int32 = match status with case Placed { total } => total case Draft => 0case Placed { total } means case Placed(Placement { total }); case Placed(placement) binds the whole record. Ordinary record pattern rules apply, including omitted fields and nested patterns. Extracting an inline record variant into a named payload can therefore preserve its brace construction and patterns.
Braces unwrap exactly one record payload. They do not unwrap newtypes, classes, or multiple positional payloads. Generic payloads work when their record type is known: Some { total = 100; placedAt = 1 } needs an expected type such as Option<Placement>. Fields alone do not identify a nominal record type. Custom construction uses the enum qualifier; patterns can infer it from the matched value.
Construction checks both the enum’s and the payload record’s construction permissions. A private record constructor does not prevent inspecting its public fields through a variant pattern or wrapping an existing record value in a publicly constructible enum.
Private Construction of Enums
Section titled “Private Construction of Enums”The same modifier reserves every variant’s construction for the enum’s associated module:
public enum Status private = Open Closed
module Status = public function open(): Status = Status.Open public function close(self): Status = Status.Closed
function isClosed(status: Status): Bool = match status with case Status.Open => false case Status.Closed => trueOutside module Status, Status.Open is allowed in a pattern but rejected as a value expression, because that would construct a value. This applies to variants with no payload, tuple payloads, and named record payloads. Patterns still expose their payloads, and exhaustiveness checking is unchanged. The with expression remains a record operation; this does not add enum updates.
Option Type
Section titled “Option Type”The prelude’s Option<T> type represents optional values. It is available without an import:
enum Option<T> = Some(T) None
function findUser(id: Int32): Option<User> = if id == 1 then Some(User { name = "Alice"; email = "a@b.com"; age = 30 }) else NoneSee Option Type in chapter 10 for its methods and guidance on handling missing values.
Result Type
Section titled “Result Type”The prelude’s Result<T, E> type represents success or failure. It is also available without an import:
enum Result<+T, +E> = Ok(T) Error(E)
function divide(a: Int32, b: Int32): Result<Int32, String> = if b == 0 then Error("Division by zero") else Ok(a / b)See Result Type in chapter 10 for its methods and the try expression for propagating failures.
6.4 Tuples
Section titled “6.4 Tuples”Tuples group multiple values of different types.
Creating Tuples
Section titled “Creating Tuples”let pair = (1, "hello")let triple = (10, 20.5, true)Accessing Tuple Elements
Section titled “Accessing Tuple Elements”Use pattern matching or numbered accessors:
let (x, y) = pair// x is 1, y is "hello"
let first = triple._0 // 10let second = triple._1 // 20.5Tuples vs Records
Section titled “Tuples vs Records”- Use tuples for quick grouping of a few values
- Use records when you need meaningful field names
- Records are better for public APIs
For generic tuple construction, shape constraints, and implementations across tuple arities, see Part 25: Tuple Extension (Advanced).
6.5 Newtypes
Section titled “6.5 Newtypes”Newtypes create distinct types from existing types, preventing accidental mixing.
Defining Newtypes
Section titled “Defining Newtypes”newtype UserId = Int32newtype OrderId = Int32newtype Email = StringUsing Newtypes
Section titled “Using Newtypes”let userId = UserId(123)let orderId = OrderId(456)
// This won't compile - different types!// let wrong: UserId = orderId // Error!Why Newtypes?
Section titled “Why Newtypes?”Newtypes prevent bugs by making the type system work for you:
function getUser(id: UserId): Option<User> = ...function getOrder(id: OrderId): Option<Order> = ...
let userId = UserId(123)let orderId = OrderId(456)
getUser(userId) // OKgetUser(orderId) // Compile error! Can't pass OrderId where UserId expectedPrivate Newtypes
Section titled “Private Newtypes”A private newtype reserves both construction and inspection for its associated module:
public newtype Password private = String
module Password = public function parse(value: String): Result<Password, String> = if value.length < 8 then Error("Password must be at least 8 characters") else Ok(Password(value))
public function matches(self, other: String): Bool = self.value == otherOnly module Password in the defining package may call Password(...), access .value, or unwrap a Password(value) pattern. Other code can store, pass, and compare passwords and call the module’s public functions. Trait implementations and extensions have no special access, even in the same package.
Private records and enums expose their fields and patterns; private newtypes keep the wrapped value opaque. All three use Name<T> private where ... = ..., but their inspection rules differ. The old newtype Name = private Type syntax is rejected.
6.6 Type Aliases
Section titled “6.6 Type Aliases”Type aliases create alternative names for existing types. Unlike newtypes, they’re interchangeable with the original type.
Defining Type Aliases
Section titled “Defining Type Aliases”type Name = Stringtype Age = Int32type Coordinate = (Int32, Int32)type UserMap = Map<UserId, User>Using Type Aliases
Section titled “Using Type Aliases”function greet(name: Name): String = "Hello, $name!"
let name: Name = "Alice"greet(name) // OKgreet("Bob") // Also OK - Name is just StringWhen to Use Type Aliases vs Newtypes
Section titled “When to Use Type Aliases vs Newtypes”- Type aliases: For convenience and readability (great for tuples and function types)
- Newtypes: When you need type safety and distinction
// Type aliases make complex types readabletype Coordinate = (Int32, Int32)type Handler = (Request) => Responsetype Callback<T> = (T) => Unit
// Newtypes create distinct typesnewtype UserId = Int32newtype OrderId = Int326.7 Lists, Arrays, and Slices
Section titled “6.7 Lists, Arrays, and Slices”List<T>, Array<T>, and Slice<T> are available from the prelude without imports. Lists are immutable linked sequences; arrays are fixed-size mutable collections; slices are writable views into arrays.
Lists and List Literals
Section titled “Lists and List Literals”Square brackets create a List<T>. The element type is inferred from the contents or the surrounding context:
let numbers: List<Int32> = [1, 2, 3]let names = ["Alice", "Bob"]let empty: List<Int32> = []let nested: List<List<Int32>> = [[1, 2], [3]]assert numbers.length == 3assert empty.isEmptyA list is an immutable singly linked sequence, defined as List<out T> = Nil | Cons(T, List<T>) in the prelude and used as the standard library’s default sequence type. It is represented by the enum variants Nil (empty) and Cons(head, tail). [] is the literal spelling of Nil; [1, 2] is equivalent to Cons(1, Cons(2, Nil)). List.empty also creates an empty list.
Use :: to prepend an element or ++ to concatenate lists. These operations leave the original lists unchanged. Prepending shares the existing tail and takes constant time; concatenation copies the left list’s nodes and takes time proportional to its length. :: associates to the right:
let original = [2, 3]let extended = 1 :: originalassert extended == [1, 2, 3]assert original == [2, 3]assert 1 :: 2 :: [] == [1, 2]assert [1, 2] ++ [3, 4] == [1, 2, 3, 4]List is covariant (out T), so an empty list fits wherever a List<T> is expected without an annotation at the call site:
function total(xs: List<Int32>): Int32 = xs.foldLeft(0, (a, b) => a + b)total([]):: binds looser than + and ++, and tighter than comparison — so x :: xs ++ ys is x :: (xs ++ ys), and x :: xs == ys is (x :: xs) == ys.
The list structure is immutable; objects stored as elements may still have mutable state.
Accessing and Matching Lists
Section titled “Accessing and Matching Lists”head and tail return the first element and the remaining list, and panic on an empty list. headOption and get(index) return Option<T> instead; get returns None for a negative or out-of-bounds index. Lists use .get(i) for indexed lookup. Both indexed lookup and length traverse the list, so use an array when frequent random access matters.
let numbers = [10, 20, 30]assert numbers.head == 10assert numbers.tail == [20, 30]assert numbers.headOption.or(0) == 10assert numbers.get(1).or(0) == 20assert numbers.get(3).isNonePatterns can match an empty list, an exact number of elements, or a head and tail:
function describeList(values: List<Int32>): String = match values with case [] => "empty" case [only] => "one element" case first :: rest => "multiple elements"Transforming and Iterating Lists
Section titled “Transforming and Iterating Lists”map, filter, take, drop, and reverse return lists without changing the original. foldLeft combines the elements into a single value. toArray() copies the elements into a new mutable array:
let numbers = [1, 2, 3]assert numbers.map(x => x * 2) == [2, 4, 6]assert numbers.filter(x => x > 1) == [2, 3]assert numbers.take(2) == [1, 2]assert numbers.drop(1) == [2, 3]assert numbers.reverse() == [3, 2, 1]assert numbers.find(n => n > 1) == Some(2)assert numbers.contains(2)assert numbers.foldLeft(0, (total: Int32, value: Int32) => total + value) == 6let array = numbers.toArray()array[0] = 99assert numbers.head == 1
let mutable sum = 0for number in numbers do sum = sum + numberassert sum == 6Lists compare by contents with == and != when their elements implement Equatable. They compare lexicographically when their elements implement Comparable.
Choosing Between Array and List
Section titled “Choosing Between Array and List”List<T> |
Array<T> |
|
|---|---|---|
| Literal | [1, 2] |
[| 1, 2 |] |
| Prepend | O(1) | O(n) |
| Index / length | O(n) | O(1) |
| Mutable | no | yes |
| Variance | covariant (out T) |
invariant |
| Pattern matching | yes | no |
Reach for a list when you build a sequence once and read it front to back, which is most business logic. Reach for an array when you index it, mutate it, know its size up front, or are holding bytes.
Creating Arrays
Section titled “Creating Arrays”Arrays are fixed-size, mutable, indexed collections. Array literals use [| ... |]; list literals use [ ... ]:
let numbers = [| 1, 2, 3, 4, 5 |]let names = [| "Alice", "Bob", "Charlie" |]let empty: Array<Int32> = [||]The plain [ ... ] form is a list literal (§6.7). Arrays get the marked
syntax because they are the specialised choice — contiguous, mutable, indexed —
while a list is the everyday sequence.
An empty array literal needs a type annotation. Array is invariant, so there
is nothing for the compiler to infer the element type from.
Use Array.fill(length, value) to create an array of a given size filled with a value:
let zeros = Array.fill(10, 0) // [|0, 0, 0, ..., 0|]let greetings = Array.fill(3, "hi") // [|"hi", "hi", "hi"|]Accessing Elements
Section titled “Accessing Elements”Use the index operator arr[i] or the .get(i) method:
let first = numbers[0] // 1let second = numbers.get(1) // 2Setting Elements
Section titled “Setting Elements”Use the index assignment arr[i] = value or the .set(i, value) method:
numbers[0] = 99numbers.set(1, 42)Array Length
Section titled “Array Length”let count = numbers.length // 5Cloning
Section titled “Cloning”arr.clone() creates an independent copy of the array:
let a = [|1, 2, 3|]let b = a.clone()b[0] = 99assert a[0] == 1 // original unchangedArray Type Syntax
Section titled “Array Type Syntax”The type of an array is written as Array<ElementType>:
function sum(numbers: Array<Int32>): Int32 = numbers[0] + numbers[1] + numbers[2]Nested Arrays
Section titled “Nested Arrays”Arrays can contain other arrays:
let matrix: Array<Array<Int32>> = [| [| 1, 2 |], [| 3, 4 |] |]let value = matrix[0][1] // 2Equality and Comparison
Section titled “Equality and Comparison”Primitive-element arrays support ==, !=, <, >, <=, >= (lexicographic comparison):
assert [| 1, 2, 3 |] == [| 1, 2, 3 |]assert [| 1, 2 |] < [| 1, 3 |]Iterating Over Arrays
Section titled “Iterating Over Arrays”for item in numbers do debug(item)Slices: Shared Array Views
Section titled “Slices: Shared Array Views”Slice<T> is available from the prelude without an import. It is a writable view of part of an Array<T>. Creating or re-slicing a view does not copy its elements.
Slicing uses [| ... |], matching the array literal delimiters. Element access and assignment still use [i]:
let values = [|10, 20, 30, 40, 50|]let middle: Slice<Int32> = values[|1..4|]assert middle.length == 3assert middle[0] == 20
middle[0] = 99assert values[1] == 99 // writes through to the original arrayvalues[2] = 77assert middle[1] == 77 // observes writes through the array too
let tail = middle[|1..|]assert tail[0] == 77 // bounds are relative to middleAll slicing forms work on both arrays and slices:
| Form | Elements included |
|---|---|
values[|i..j|] |
From i up to, but excluding, j |
values[|i..|] |
From i through the end |
values[|..j|] |
From zero up to, but excluding, j |
values[|..|] |
The whole array or slice |
values[|i..=j|] |
From i through j, including j |
values[|..=j|] |
From zero through j, including j |
values[|i|] and values[i..j] are invalid. Use values[i] for one element. Range assignment is unsupported; use copyTo to copy several elements. The range notation belongs to slicing syntax: let range = 1..3 is not a valid expression.
Slice Bounds and Constructors
Section titled “Slice Bounds and Constructors”Bounds are Int32 values. A half-open interval requires 0 <= start <= end <= length. An inclusive end must refer to an existing element. Invalid construction, re-slicing, or element access panics; negative indices do not count back from the end. Empty half-open slices are valid, including one at the array’s end:
let empty = values[|values.length..|]assert empty.isEmpty()
let whole = Slice.full(values)let window = Slice.make(values, 1, 3) // start and lengthassert window == whole.slice(1, 4) // start and exclusive endassert window == whole.sliceInclusive(1, 3)assert window == whole.drop(1).take(3)make takes a length; slice takes an exclusive end. The backing array, start, and length are private. Copying a slice value shares the same view of the data. Ordinary locals, parameters, and returns carry the view directly; storing it at an erased or boxed boundary, such as an array element or Option<Slice<T>> payload, can allocate a box for the view.
Copying, Comparing, and Iterating
Section titled “Copying, Comparing, and Iterating”toArray() produces an independent copy. copyTo(destination) copies all source elements into a destination slice whose length must be at least the source length. It also works when the views overlap:
let copied = middle.toArray()copied[0] = 0assert middle[0] == 99
let shifted = [|1, 2, 3, 4|]shifted[|..3|].copyTo(shifted[|1..|])assert shifted == [|1, 1, 2, 3|]Read-only views use the private tuple newtype ReadonlySlice<T>:
let array = [|10, 20, 30|]let view = array[|1..|].readonlyassert view[0] == 20let subview = view[|..1|] // also ReadonlySlice<Int32>let copy = view.toArray() // independent mutable arrayarray[1] = 99assert view[0] == 99 // read-only does not freeze other aliasesassert copy[0] == 20Arrays and slices expose .readonly without copying elements. Read-only views support the same checked ranges, iteration, equality, and display as mutable slices. map returns fresh backing storage as a read-only view; copyTo accepts a mutable destination slice. Indexed writes and .set are unavailable. Both slice types use trait-based element access: Index<Int32> for reads, and IndexSet<Int32> for mutable writes.
Slices compare by contents with == and != when their elements implement Equatable, even if they view different arrays. They support Display when their elements do. Use for to iterate over a slice:
let mutable total = 0for value in middle do total = total + valueassert total == 216Slices and Strings
Section titled “Slices and Strings”Strings do not support slicing syntax. Convert explicitly to a character array and then create a view. chars() copies the characters once; subsequent slicing shares that array:
let characters = Slice.full("hello".chars())let greetingStart = characters[|..2|]assert greetingStart[0] == 'h'assert greetingStart[1] == 'e'6.8 Modules
Section titled “6.8 Modules”A module is a named container that groups functions, properties, and top-level let (globals) under one name (similar to F# modules). Modules do not contain type declarations. You use its contents via the qualifier: ModuleName.member. There are two kinds of modules, depending on whether a type with the same name exists in the package. In a generic module (e.g. module Array<T> =), all members and globals have the type parameter in scope, and globals are instantiated per type argument (like C#): each Array<Int32>, Array<String>, etc. has its own copy of each static and each top-level let.
Standalone modules
Section titled “Standalone modules”When no type in the package has the same name as the module, it’s a standalone module. It can contain only static functions, static properties (property name(): Type = ...), and top-level let (static). No instance members.
package standard
module Math = let Pi: Float64 = 3.141592653589793 property pi(): Float64 = 3.141592653589793 function floor(x: Float64): Int32 = ... function ceil(x: Float64): Int32 = ...You import the module as a whole and use qualified names:
import standard.Math
let x = Math.floor(3.14)let p = Math.PiYou cannot import a single member (e.g. import standard.Math.floor is not allowed).
Modules for a type
Section titled “Modules for a type”When a type with the same name as the module exists in the package (e.g. type Array<T> and module Array), the module is a module for that type. It may contain instance methods (functions with self), instance properties (property name(self): Type = ...), static methods and static properties, and top-level let (static/global). For a generic type, the module must declare the type parameters: module Array<T> =.
In a generic module, all members and top-level let have the type parameter T in scope. Static members and globals are instantiated per type argument (like C#): Array<Int32>.defaultCapacity and Array<String>.defaultCapacity are different storage locations.
package standard.prelude
// Type Array<T> is declared elsewhere (e.g. intrinsic).module Array<T> = let defaultCapacity: Int32 = 16 property empty(): Array<T> = ... function fill(size: Int32, value: T): Array<T> = ... function get(self, index: Int32): T = ... function set(self, index: Int32, value: T): Unit = ... property length(self): Int32 = ...Instance members are always available on the type. You do not need to import the module to call arr.length or arr.get(0) — whenever the type is in scope, the instance methods and properties from that type’s module are available.
Static methods can infer the module’s type arguments from call arguments or an expected return type: Array.fill(5, 42) infers Int32, and let empty: Array<Int32> = Array.empty() uses the annotation. You can also supply them explicitly, as in Array<Int32>.fill(5, 42). For a generic global, qualify its instantiation explicitly, such as Array<Int32>.defaultCapacity. Static use requires the module to be in scope (same package, prelude, or imported).
A package may have at most one module for a given module name (standalone or for a type).
6.9 Extension Methods
Section titled “6.9 Extension Methods”Extension methods let you add methods to types you don’t own — especially types from another package. Every extension has a name (e.g. UserHelpers) and defines methods (and static methods) on a type. Extensions must be imported to be used; they are not attached automatically.
package myapp
import users.User // User is defined in another package
extension UserHelpers for User = function displayName(self): String = "${self.firstName} ${self.lastName}"Why give extensions a name and require import?
- Explicit imports — You must import the extension to use its methods, so it’s clear where they come from.
- Avoid conflicts — Different packages can define extensions with different names for the same type.
- Clear ownership — The extension name shows which package or layer provides the behavior.
Using extensions:
package app
import users.Userimport myapp.UserHelpers // Must import the extension
function main() = let user = User { firstName = "Alice"; lastName = "Smith" } debug(user.displayName()) // Works because UserHelpers is importedFor types in the same package, instance methods are usually provided by a module for that type (a module with the same name as the type), not by an extension. See Modules above. Use an extension when you want to add methods to a type from another package or when you want a separate, explicitly imported set of methods.
6.10 The Any Type and Type Casting
Section titled “6.10 The Any Type and Type Casting”The Any Type
Section titled “The Any Type”Any is the top type in Dovetail — every type is assignable to Any:
let a: Any = "Hello"let b: Any = 42let c: Any = truelet d: Any = Point { x = 1; y = 2 }This makes Any useful for heterogeneous collections, generic APIs that accept values of unknown type, and interop scenarios. Under the hood, primitive values (integers, floats, booleans, etc.) are automatically boxed when stored as Any, and unboxed when cast back to their concrete type.
Type Testing with is
Section titled “Type Testing with is”The is expression checks at runtime whether a value has a specific type. It returns Bool:
function describe(x: Any): String = if x is String then "a string" else if x is Int32 then "an integer" else if x is Bool then "a boolean" else "something else"is supports concrete target types, including primitives, records, enums, generic types, and arrays:
let x: Any = Some(42)x is Option<Int32> // truex is Option<String> // falsex is String // falseType Casting with as
Section titled “Type Casting with as”The as expression casts a value to a specific type. If the cast fails at runtime, it panics (there is no safe as? variant):
let x: Any = 42let n: Int32 = x as Int32 // succeeds, n is 42
let y: Any = "hello"let s: String = y as String // succeeds, s is "hello"For primitive types, as automatically unboxes the value:
let x: Any = 3.14let f: Float64 = x as Float64 // unboxes to 3.14For records and enums, as returns the reference directly:
record Point = x: Int32 y: Int32
let x: Any = Point { x = 1; y = 2 }let p: Point = x as Pointassert p.x == 1The is + as Pattern
Section titled “The is + as Pattern”Since as panics on failure, the idiomatic pattern is to test first with is, then cast:
function tryGetInt(x: Any): Option<Int32> = if x is Int32 then Some(x as Int32) else NoneOr in a more complex example:
function processValue(x: Any): String = if x is Int32 then let n = x as Int32 "integer: ${n * 2}" else if x is String then let s = x as String "string of length ${s.length}" else "unknown"Restrictions
Section titled “Restrictions”For the concrete type recovery shown here, the subject is Any. These operators
also support class type tests and casts and
interface upcasts, subject to their own rules.
They do not convert unrelated concrete types:
let x: Int32 = 42// x is String // compile error: 'is' requires subject of type Any// x as String // compile error: 'as' requires subject of type AnyFor recovery from Any, the target must be concrete; Any, Never, and
interface targets are unsupported:
let x: Any = 42// x is Any // compile error: target must be a concrete type// x is Never // compile error: target must be a concrete typeWorking with Generic Types
Section titled “Working with Generic Types”is and as work with generic type instantiations:
let x: Any = Some(42)if x is Option<Int32> then let opt = x as Option<Int32> match opt with case Some(n) => assert n == 42 case None => panic "unexpected"Different instantiations are distinguished at runtime:
let a: Any = Some(42)let b: Any = Some("hello")
a is Option<Int32> // truea is Option<String> // falseb is Option<String> // trueWorking with Arrays
Section titled “Working with Arrays”Array element types are also distinguished:
let x: Any = [|1, 2, 3|]x is Array<Int32> // truex is Array<String> // false6.11 Interfaces and Interface Types
Section titled “6.11 Interfaces and Interface Types”An interface describes behavior and can also be the type of a value. It lets a
parameter, return value, field, or collection hold different implementations of
the same contract. A plain trait describes behavior for generic bounds; it
cannot appear as a value type. Both use implement blocks, and an interface can
also be used as a generic bound.
Declaring and Implementing Interfaces
Section titled “Declaring and Implementing Interfaces”interface Label = function text(self): String
record Person = name: String
record NumberLabel = value: Int32
implement Label for Person = function text(self): String = self.name
implement Label for NumberLabel = function text(self): String = "${self.value}"
record Badge = label: Label
function readLabel(label: Label): String = label.text()
function chooseLabel(person: Bool): Label = if person then Person { name = "Ada" } else NumberLabel { value = 42 }
function main(): Unit = let label: Label = Person { name = "Ada" } assert label.text() == "Ada" assert readLabel(NumberLabel { value = 42 }) == "42" assert chooseLabel(false).text() == "42" let badge = Badge { label = Person { name = "Grace" } } assert badge.label.text() == "Grace" let labels: List<Label> = [Person { name = "Ada" }, NumberLabel { value = 42 }] for item in labels do assert item.text().length > 0The compiler implicitly converts each concrete value to Label where that type
is expected. Calling text through Label dispatches to the implementation of
the value held at runtime. Records, enums, primitives, and classes can implement
interfaces. Only the interface’s members are exposed through an interface value.
Type annotations make the intended common interface explicit. They also allow a
list literal to contain different concrete implementations. An existing
List<Person> does not automatically become List<Label>: convert the elements
when building the new collection. The same boundary applies to containers such
as Option<T> and to function types; coercion does not recursively adapt them.
Generic Interfaces and Bounds
Section titled “Generic Interfaces and Bounds”An interface can have type parameters:
interface Source<T> = function get(self): T
record Fixed = value: Int32
implement Source<Int32> for Fixed = function get(self): Int32 = self.value
function read<T>(source: T): Int32 where T: Source<Int32> = source.get()
function main(): Unit = let source: Source<Int32> = Fixed { value = 7 } assert source.get() == 7 assert read(Fixed { value = 9 }) == 9 assert read(source) == 7Source<Int32> fixes the element type while hiding the implementation. A generic
bound retains the caller’s type: passing Fixed allows static dispatch, while
passing an interface value still calls through that value’s interface. Individual
interface methods cannot introduce their own type parameters.
Intersections and Upcasts
Section titled “Intersections and Upcasts”A and B is a value type requiring both interfaces. Its components must all be
interfaces, and the concrete value must implement every component:
interface Named = function name(self): String
interface Counted = function count(self): Int32
record Team = title: String members: Int32
implement Named for Team = function name(self): String = self.title
implement Counted for Team = function count(self): Int32 = self.members
function main(): Unit = let team: Named and Counted = Team { title = "Core"; members = 3 } assert team.name() == "Core" assert team.count() == 3 let named: Named = team let counted = team as Counted assert named.name() == "Core" assert counted.count() == 3An intersection implicitly upcasts to a component or a smaller intersection;
as can also express an upcast. An interface extending another interface can
likewise upcast to its parent. These conversions expose fewer capabilities and
do not discover new ones at runtime. If components declare the same member,
qualify the call, such as Named.name(team), to select the contract explicitly.
Inheritance, Defaults, and Self
Section titled “Inheritance, Defaults, and Self”Interfaces share inheritance, default methods and properties, and explicit member qualification with traits. See Part 8, sections 8.5–8.7 for the implementation rules. An interface can extend only other interfaces; a trait can extend traits or interfaces.
An interface method may return bare Self:
interface Stepper = function step(self): Self function value(self): Int32
record Counter = n: Int32
implement Stepper for Counter = function step(self): Counter = Counter { n = self.n + 1 } function value(self): Int32 = self.n
function main(): Unit = let counter: Stepper = Counter { n = 4 } let next: Stepper = counter.step() assert next.value() == 5Through an interface value, the result has the interface type that declares the
method. For an intersection receiver, it has that declaring component’s type,
so calling a Self-returning method does not preserve the whole intersection.
Interface Restrictions and Any
Section titled “Interface Restrictions and Any”The compiler checks these rules when the interface is declared:
- Every method takes
self; static methods are not allowed. - Methods cannot declare their own generic parameters.
- Associated types, including generic associated types, are not allowed.
Selfis allowed as the receiver and as a bare return type. Parameters such asother: Selfand nested returns such asOption<Self>are not allowed.- Parent contracts in
extendsmust themselves be interfaces.
Use a trait when a contract needs these excluded features. For example,
equals(self, other: Self) belongs in a trait because both arguments must have
the same concrete type.
Interface values and Any serve different
purposes. An interface exposes a known contract. Any requires a concrete type
test or cast before using the value. Testing value is Label or casting an
Any value to Label is unsupported. Storing an interface value in Any retains
the interface wrapper: testing that Any for the underlying record type returns
false. Store the concrete record directly in Any when concrete type recovery
is needed.
Choosing a Contract
Section titled “Choosing a Contract”| Need | Use |
|---|---|
| Heterogeneous collection, runtime-selected return, or injected dependency field | Interface |
| One contract usable both as a generic bound and a value type | Interface, within the declaration restrictions |
| Same-type arguments, static methods, associated types, or generic methods | Trait |
| Generic behavior where static dispatch is sufficient | Trait |
| A closed, known set of alternatives with pattern matching | Enum |
| Shared state and a single class inheritance hierarchy | Abstract class |
Private Construction for Records and Enums
Section titled “Private Construction for Records and Enums”A public type can expose its shape while reserving construction for its associated module:
public record Account private = balance: Int32
module Account = public function make(balance: Int32): Account = assert balance >= 0 Account { balance = balance }
public function deposit(self, amount: Int32): Account = assert amount >= 0 self with { balance = self.balance + amount }
public enum Status private = Open Closed
module Status = public function open(): Status = Status.Open public function close(self): Status = Status.ClosedOutside module Account, reading account.balance and matching record fields
are allowed; record construction and with expressions are rejected. Outside
module Status, variants can appear in patterns but cannot construct values.
The module must belong to the type’s defining package. Trait implementations and
extensions must call module functions for construction and updates.
Place the modifier after type parameters and before constraints, for example
record Box<T> private where T: Equatable = .... Empty private records use
record Token private. Leading public controls visibility of the type itself.
Private newtypes also use this placement, but retain stronger inspection rules:
only their module can access .value or unwrap a constructor pattern. Trait
implementations must use module functions for those operations too. Derive macros
receive no exemption from these restrictions. The old newtype Name = private Type
syntax is rejected.
Choose privacy per type. If OrderStatus is private, module Order cannot directly
construct OrderStatus.Placed(...); it must use module OrderStatus functions.
An aggregate can instead keep Order construction private while leaving its
status enum publicly constructible.
Summary
Section titled “Summary”-
Type Inference: Dovetail infers types when possible, but you can be explicit
-
Records: Data-only types with named fields, immutable by default
-
Enums: Discriminated unions that can carry data per variant
-
Tuples: Positional grouping of values (anonymous types; access via _0, _1, or destructuring)
-
Newtypes: Distinct types for type safety
-
Type Aliases: Convenient names for existing types
-
Lists: Immutable linked sequences, with
[ ... ]literals,::prepending, and++concatenation -
Arrays: Fixed-size indexed collections, with
[| ... |]literals -
Slices: Writable shared array views, with
[|start..end|]slicing and[i]element access -
Modules: Named containers (standalone or for a type); qualified use; instance members of a module-for-type are available on the type. In generic modules, all members and globals have T in scope and are instantiated per type argument (C#-style); static methods can infer type arguments; generic globals use an explicit instantiation such as
Array<Int32>.defaultCapacity -
Extension methods: Add methods to types (e.g. from other packages); always named and must be imported to use
-
Any type: Top type; every value is assignable to
Any; useisto test andasto cast back to concrete types -
Interfaces: Contracts usable as value types and generic bounds; implicit coercion enables runtime dispatch, heterogeneous collections, and interface intersections
