A small tour of Seamless
A short orientation to Seamless values, types, modules, unions, and typeclasses.
This is a short orientation to the language as it exists today.
It is not meant to be a complete tutorial. The syntax is still moving, names are still provisional, and some parts of the language are more mature than others. The goal here is only to make the examples in these notes easier to follow.
Values and primitive types
Seamless is statically typed, and the types are inferred. Values have types such as:
| Type | Example | Description |
|---|---|---|
Int | 42 | 32-bit integer |
Boolean | true, false | boolean value |
String | "hello" | immutable text backed by valid UTF-8 bytes |
Double | 1.5 | 64-bit floating-point value |
Unit | {} | no meaningful value to return |
The compiler does not need a signature to infer a function type. This function has no explicit signature:
module Example.Numbers
import Kernel.Int (+)
def add_one(value) {
value + 1
}The use of + tells the compiler that value must be an Int, so the inferred type is:
add_one : Int -> IntExplicit signatures are still useful as checked documentation and module boundaries:
import Kernel.Int (+, <)
@type (Int, Int) -> Boolean
def small_sum?(left, right) {
left + right < 10
}The signature says small_sum? takes two Int values and returns a Boolean. The compiler checks that the body actually matches that signature.
Both examples also show another basic rule: Seamless is expression-based. There is no return statement because a function body evaluates to a value. The last expression in the block is the result of the function.
That also means there is no early return in the usual imperative sense. Control flow is expressed with value-producing forms such as if and case.
Operators are functions too. The + and < operators above are imported from Kernel.Int, then used infix. The same calls can be written explicitly:
Kernel.Int.<(Kernel.Int.+(left, right), 10)The infix form is mostly a readability convenience. It is not a separate hidden operator system.
The usual arithmetic and comparison precedences apply, so left + right < 10 parses as (left + right) < 10. When in doubt, use parentheses; clever precedence is not meant to be part of the experience.
Type errors
Because types are inferred, the compiler can reject inconsistent code even when no signature was written.
For example, add_one was inferred as taking an Int, so this does not typecheck:
def bad_argument() {
add_one("oops")
}A full diagnostic includes the module and source location. Trimmed down, the error is:
CompileError: cannot unify String with Int
add_one("oops")
~~~~~~Branches also have to agree on a result type:
def bad_branch(flag) {
if flag {
1
} else {
"one"
}
}That fails because one branch returns an Int and the other returns a String:
CompileError: cannot unify Int with String
1
^This is the kind of basic safety the language is built around. Every function has a type, and every path through the body has to fit that type.
Type constructors
Some types take type arguments:
Array(String)
Maybe(Int)
Result(String, Error)Array(String) means an array whose elements are strings.
Maybe(Int) means either an Int exists or it does not.
Result(String, Error) means either a string was produced successfully or an error value was produced instead.
The Maybe and Result types are introduced in more detail below.
Lowercase names such as a, e, or r are commonly used as type parameters. A function declares the type parameters it introduces with @forall:
@forall a
@type (a) -> a
def identity(value) {
value
}This function works for any a. It takes a value of some type and returns a value of the same type.
Modules
A source file starts with a module declaration:
module Data.MaybeModules own names. Imports make names from other modules available:
module Example.Main
import Std.Data.Maybe (Some, None)
import Kernel.StringThe source file inside the standard-library package declares module Data.Maybe; outside that package, the package name is included, so user code imports it as Std.Data.Maybe.
The exact import surface is still being refined, but the basic idea is ordinary: modules are the unit of naming and visibility.
One important convention in the current language is the primary declaration.
A module often has one main declaration whose name comes from the module name. For example, the module Data.Maybe defines the primary type Maybe.
module Data.Maybe
union(a) {
Some(a)
None
}The declaration is not written as union Maybe(a) here. The module name supplies the primary name.
This is the general primary declaration convention: when a module has an unnamed primary declaration, that declaration takes the current module’s short name.
The reason for this is namespacing. We often want a group of functions to live with a type or concept: Data.Maybe defines the Maybe type, its constructors, and helper functions such as Maybe.with_default(value, fallback). Making the main declaration primary avoids the need for additional structure to associate functions with the type. This style is strongly inspired by Elixir’s module-first organization.
So the union declaration above defines:
Maybe(a)
Some(a)
NoneThe surface syntax may change, but the goal is stable: a module should have a close relationship with its main type or concept.
Maybe and Result
The Maybe type shown above is the simplest way to model a value that might not exist. A Maybe(String) is either Some(text) or None.
Code handles those cases with case:
@type (Maybe(String), String) -> String
def with_default(value, fallback) {
case value {
Some(text) => text
None => fallback
}
}Result is the same idea for operations that can fail with an error value:
module Result
union(a, e) {
Ok(a)
Err(e)
}A Result(String, HttpError) is either Ok(text) or Err(error).
That gives ordinary failure a place in the type:
@type (Result(String, String)) -> String
def describe(result) {
case result {
Ok(value) => value
Err(message) => message
}
}This is one of the main design biases of Seamless: if something is an expected outcome, it should be represented as data.
Typeclasses
Seamless currently uses typeclasses for generic interfaces.
The terminology is borrowed from Haskell, but the name is not settled. These may eventually be called interfaces instead of classes, partly because class means something very different to many web developers.
The current design is intentionally conservative. An instance must live in the module that defines the class, or in a module that defines one of the types in its instance head, and overlapping instance heads are rejected. For example, two different Show(Int) instances are not allowed. The goal is predictable dispatch, not maximal ad-hoc extension.
For example, Show describes values that can be converted to strings:
module Data.Show
class(a) {
show: (a) -> String
}An instance provides show for a concrete type:
instance Show(Int) {
def show(value) {
String.from_int(value)
}
}Generic code can ask for a class constraint:
@forall a
@where Show(a)
@type (a) -> String
def label(value) {
show(value)
}The @where Show(a) annotation says this function works for any a, as long as a has a Show instance.
As with ordinary functions, the annotation is not strictly required. The type system can infer the same constrained type from the call to show:
def label(value) {
show(value)
}The constraint is still part of the inferred type. The compiler is proving that a matching Show instance exists wherever label is used.
Shell.Main> \type label
label : ((forall (t0)) (type (t0 String)) (where (Std.Data.Show t0)))A starting point
This is not an exhaustive tour of the language. Just enough to show the feel and direction of Seamless, and enough vocabulary to make future examples easier to read.
Future notes will go deeper into the rest of the system: failure as data, capability-gated primitives, effects, macros, resources, interop, and the path down to Wasm.