Types
This specification owns Flow's type formers: records, enums, tuples, function
types, aliases, Unit and Never. It owns visibility of types, fields and
constructors, the opaque marker, generic parameters and how type arguments are
found, how a constructor name resolves, type equality, and the data-type
category with the equality, ordering, text and schema every data type gets.
Source spelling belongs to grammar.md. The built-in data types and what their values mean belong to data.md. The effect a function type carries belongs to effects.md. What may cross a Tool boundary, and what decoding may build, belongs to tools.md. Static acceptance and its diagnostics belong to checking.md.
Flow is statically typed. Every expression has one type before the program
runs. There is no subtyping, no implicit conversion, no reflection and no
runtime type information. No value inhabits every type: there is no null and no
default value, and an absent value is an Option (data.md).
Every rule here binds every conforming implementation. Examples and the stated reasons for a refusal are non-normative.
Type formers
A type is one of:
Name<A, ...> a declared type: record, enum, alias, or a bodyless type std declares
(A, B, ...) a tuple
(A, ...) -> R !e a function type, with its effect
T a type parameter, inside the declaration that lists itThe list is closed. The built-in types, such as Int, Text, List<T>,
Dict<K, V>, Option<T> and Result<T, E>, are not formers of their own. Each
is declared in std source, and the compiler knows it by its @lang mark, never
by its name (stdlib.md). A bodyless declaration such as
@lang(int, data) type Int says the compiler implements the type;
@tool type Page says the host does (tools.md).
Every type is nominal except tuples and function types, which are structural. Two records with the same fields in different modules, or in one module under two names, are different types.
Records
A record is a named product of named fields:
type State { rounds: Int, latest: Batch }
pub type Row { pub id: Text, pub quantity: Text }Open in playground →A record value has every field its type declares, exactly once, and no other.
Field order in a declaration fixes the type's ordering (Equality, ordering and
text); the order fields are written in when a value is built changes nothing.
A field is read with ., as in row.id. Building, updating with ..base and
dotted paths, and field shorthand are semantics.md's.
Enums
An enum is a named sum of constructors. A constructor carries no payload, a positional payload, or a named payload built and matched like a record:
pub type Outcome {
Done(Report),
Picked(Text),
StepFailed { step: Int, last: Text },
TimedOut,
}Open in playground →Constructor names are unique within their enum. Declaration order is the constructor order, and it is part of the type: it fixes how values order.
A distinct type over an existing one is a one-constructor enum:
type UserId { UserId(Text) }Open in playground →Constructors
Constructors belong to their type. A constructor is written bare wherever the
expected type is known, and qualified by its type elsewhere, Answer.Dismissed,
or by module and type across modules, catalog.Answer.Dismissed. A bare name
that could be a constructor of more than one type in reach is an error, and the
diagnostic names the candidates. The expected type is known once inference over
the whole function body fixes it (Generics), even by a use written later.
flow ask() -> Answer = Dismissed // the result type is known
let x = Dismissed // error: Answer.Dismissed or Choice.Dismissed?
let y = Answer.Dismissed
match task.stop(a) { Finished(v) => ..., Stopped => ... } // resolved against task.StopOpen in playground →In a match, every arm resolves its constructors against the type of the
matched value. A constructor is also a function from its payload to its type,
so it can be passed where a function is expected, as in x ? Rejected
(errors.md).
A program's enum may use a constructor name that a prelude type also uses, such
as Failed or Unknown; where the bare name is ambiguous, it is qualified by
its type: ToolProblem.Unknown. The rule that prelude names can't be declared
again covers type and function names only (modules.md).
Tuples, Unit and Never
A tuple groups values of any number of types without naming them:
(Int, Text), (State, Event, Int). A tuple's parts are taken out by pattern
matching, never by position:
let (kept, dropped) = list.partition(findings, flow(f) = f.open)
match (state, event) {
(Idle, Start(job)) => ...,
(Running(job), Cancel) => ...,
_ => ...,
}Open in playground →Unit has exactly one value, (). A statement has type Unit
(checking.md).
Never has no values. It is the type of an expression that never produces a
value: return x, a guard's else block, and a call to a function that never
returns. It can be written in a signature:
flow listen(state: State) -> Never !tool = listen(next(state))
@tool pub flow sleep(d: duration.Duration) -> Result<Unit, Never>Open in playground →An expression of type Never is accepted where any type is expected, since no
value of it ever arrives:
let plan = match reply { Ok(p) => p, Err(problem) => return Failed(problem) }Open in playground →That allowance is for expressions only. A type containing Never is not
assignable to one containing something else: Result<Unit, Never> is not
Result<Unit, E>.
Serves foundations: a general core, bounded in scope.
Function types
A function type lists its parameter types, its result type, and its effect:
(Item) -> View is pure, (Text) -> Reply !tool may make Tool calls. Named
functions, anonymous functions, constructors and @tool functions are all
values of function types. What the effect mark means, and how an unmarked
function type inside a parameter passes its effect through, is
effects.md's.
A function stored in a field is called as (p.run)(x)
(semantics.md).
Aliases
A type alias names an existing type without making a new one:
type Reply<T> = Result<T, ToolProblem<ChatError>>Open in playground →Reply<Plan> and Result<Plan, ToolProblem<ChatError>> are the same type,
everywhere. Every judgment in this specification is made after aliases are
expanded. An alias can't reach itself, directly or through other aliases; a
recursive type is an enum or a record, which expansion never opens.
Generics
Generic parameters are listed in <...> on a type or a function:
type Tree<T> { Leaf(T), Node(List<Tree<T>>) }
pub flow map<A, B>(items: List<A>, operation: (A) -> B) -> List<B> = ...Open in playground →Type arguments are never written at a call. They are inferred across the whole function body, so one annotation anywhere that fixes the type is enough:
let names: List<Text> = list.empty()
let plan: Plan = match model.extract(prompt) { Ok(p) => p, Err(problem) => return Failed(problem) }Open in playground →A type argument that nothing in the body determines is an error at the expression that needs it.
A type parameter's requirements are inferred from what the body does with it,
and are never written. Using ==, an ordering operator, interpolation or a
generic Tool on a T requires T to be data (The data-type category);
decoding into a T also requires it to have no opaque part
(tools.md). Those are the only requirements a type parameter can
have. Each is checked at compile time at the call that supplies the type, and
tooling shows it on hover and in generated documentation.
A local type declares its own type parameters; it can't use the enclosing function's (semantics.md).
Recursive types
A type that refers to itself does so with its own parameters, unchanged.
Tree<T> inside Tree<T> is accepted; Nest<List<T>> inside Nest<T> is an
error. With this rule every type has finitely many shapes, so every data type
has a finite schema and functions over it remain inferable.
Visibility
Everything is private to its module unless marked pub:
- A
pubrecord exposes its type. Each field is exposed only if it is markedpubitself. - A
pubenum exposes all of its constructors and their payload fields. There is no per-constructorpub. - A type marked
opaqueexposes none of its constructors or fields, and no decoder can build it, in any module, its own included (tools.md). A record can beopaqueas well as an enum.
pub type Session { pub id: Text, token: Text }
pub type Answer { Accepted(Text), Dismissed }
pub opaque type Approved { Approved(Plan) }Open in playground →Building a record raw needs access to every field, so a record with a private
field is built only inside its module. Outside it, the public fields can be
read (s.id) and matched with .. (Session { id, .. }), but the value can't
be rebuilt with ..s or a dotted update, which would bypass the module.
An opaque type is how a module guards a value it hands out only after a check:
its own functions are the only way to make one. Private fields hide parts from
other modules but don't stop decoding; a type that must not be forged is
declared opaque.
Visibility controls how a value is built and named. It is not a confidentiality rule: a private field is still part of the value, and crosses a Tool boundary with it (tools.md).
Type equality
Two types are equal when, after alias expansion:
- they are the same declared type with equal type arguments;
- they are tuples of the same size with equal parts in order;
- they are function types with equal parameter types, equal result types and the same effect;
- they are one type parameter of one declaration.
A value is accepted where a type is expected only when its type equals that
type. There is no subtyping, no variance, and no numeric widening: there is one
integer type, Int, and a value that must be non-negative is checked where
that matters. The one allowance is an expression of type Never (Tuples,
Unit and Never). How a pure function fits where a !tool one is allowed
is effects.md's.
The data-type category
Every type is data or not, and the compiler works it out:
| Type | Data |
|---|---|
| a record, enum or tuple whose parts are all data | yes |
a bodyless type std declares as data, such as @lang(int, data) type Int |
yes |
any other bodyless type: task.Task<T>, every @tool type |
no |
| a function type | no |
| anything that contains a type that isn't data | no |
A generic type is judged after its arguments are substituted: Tree<Int> is
data, Tree<task.Task<Int>> is not. Never is data: std declares it so, and
having no values it has nothing to encode, which lets Result<T, Never> cross a
Tool boundary. Being data is a property of the type alone; visibility and
opaque don't change it.
Why the three that aren't: whether two functions do the same thing can't be
decided, and comparing where they live would answer differently on replay; a
task is running work, not a value, and two tasks returning the same value are
still different work; only the host knows what a @tool type value is.
The data category decides which types can be a run's input or result, a Tool's argument or reply, and a checkpoint's arguments. Those positions are owned by tools.md, history.md and semantics.md.
Serves foundations: typed, composable crossings, crash-resumable execution.
Equality, ordering and text
Every data type gets == and !=, the ordering operators <, <=, > and
>=, text through ${...} interpolation, and a schema, automatically. Nothing
is written to ask for them: there are no traits, no derive, and no constraint
syntax. One category decides all four, so they can't drift apart: there is no
type that prints but can't be compared.
On a type that isn't data, each of them is a compile error, never a runtime fault. The error names the type that fails, the part that makes it fail, and the line that needs the operation:
type Step { name: Text, run: (Input) -> Output !tool }
flow same(a: Step, b: Step) -> Bool = a == b
// error: Step can't be compared: its field `run` is a function
flow unique(running: List<task.Task<Report>>) -> List<task.Task<Report>> =
list.dedupe(running)
// error: Task values can't be compared, and list.dedupe compares its itemsOpen in playground →To compare values that carry one, compare the parts that matter:
list.dedupe_by(steps, flow(s) = s.name).
Equality is structural. Two values of one data type are equal when they are the same constructor with equal payloads, the same record with equal fields, or tuples with equal parts. Nothing about where or how a value was built is ever compared. Two equal values behave the same under every operation in the language.
Ordering is structural and total. Enum values order by their constructors'
declaration order, then by payload. Records order field by field in declaration
order, and tuples part by part. Each built-in type's own order, such as numbers
by value and text by code point, is data.md's. Any other order is
passed as a function: list.sort_by(findings, flow(f) = f.severity).
Text of a data value is structural: records and enums print as their fields and constructors. Interpolation accepts any data type (grammar.md owns the spelling), and the text of a value is the same in every implementation of one language version:
()forUnit, and a tuple's parts between(and):(1, "a");- a record as its type's name, then its fields in declaration order between
{and}, each asname: value:Row { id: "a", quantity: 3 }, orRow {}with none; - an enum value as its constructor's name, then a positional payload between
(and),Failed("late"), or a named one as a record's fields,StepFailed { step: 2, last: "x" }; a constructor with no payload is its name alone,TimedOut.
Parts are separated by , . A type's name is written as declared, without a
module or type arguments. Private fields print like any other, since
visibility is not confidentiality. Each built-in type's own text is
data.md's.
Schemas describe a data type to a Tool. They are generated from the type, and abi.md owns their format.
Every one of these operations is pure (effects.md). Anything custom, such as a conversion, a custom order or a custom rendering, is an ordinary function passed as an argument.
Serves foundations: deterministic reconstruction.
Considered and set aside
Traits, typeclasses or derive. A second language for a small
orchestration language, with dispatch the reader can't see.
A written constraint (<T: Eq>, comparable). The compiler infers it, so
writing it is noise; tooling shows it.
== on every type, faulting at run time. Deduplicating tasks would compile
and fail during a run.
Only built-in types comparable. Programs couldn't sort their own enums.
Subtyping, and a separate non-negative integer type. Either needs conversions or widening at every boundary. A non-negative value is checked where it matters.
Nested recursive types. Nest<List<T>> inside Nest<T> has infinitely
many shapes: no finite schema, no inference over it, and compilers that
instantiate per type loop. Relaxing the rule later breaks nothing.
Per-constructor pub. Matches outside the module could no longer be
exhaustive. An enum is public whole or opaque.
One constructor namespace per module, or always-qualified constructors. The
first makes two enums in a module clash; the second makes every match noisy.
Rebuilding a record with private fields from outside its module. It would
let ..s bypass the module's own functions.
A built-in dynamic type. Runtime-shaped data is typed through a generic Tool or carried as an ordinary enum (tools.md).