Types
Primitives
Section titled “Primitives”let name: string = "Alice"let age: number = 30let active: boolean = trueDeclaring Types
Section titled “Declaring Types”Floe has two type-declaration keywords:
typedeclares a nominal type — a brand-new identity distinct from anything else. Use it for records, tagged sums, and newtypes.typealiasnames an existing structural shape. The alias and its RHS are interchangeable; nothing new is created. Use it for function types, generic applications, intersections,typeof, and shape-only record aliases.
The RHS shape determines what you can use under each keyword:
| RHS | type | typealias |
|---|---|---|
{ field: T, ... } | Nominal record | Structural record alias |
A | B | ... constructors | Tagged sum | Error — tagged unions need nominal identity |
Name(T) | Newtype | Error — newtypes need nominal identity |
(T, ...) -> Ret | Error — function types have no nominal identity | Function-type alias |
A & B | Error — intersections are structural | Structural intersection |
typeof value | Error — typeof is structural | Structural typeof alias |
Partial<T> / ReturnType<...> / any generic | Error — generic applications are structural | TS utility alias (pass-through) |
If you need nominal identity around a structural shape (e.g. a HashedPassword wrapping a string), use opaque type — it is the explicit way to brand a structural type as nominal.
Records
Section titled “Records”type User = { name: string, email: string, age: number,}Construct records with brace form, Type { field: value, ... }:
Brace form resolves the type name in the type namespace only. Paren-form
construction (User(...)) is reserved for the value namespace — functions,
variant constructors, opaque-module helpers — so a TypeScript-imported
function User and interface User from the same module never collide.
Field punning works for record literals: { name } and { name: } both
desugar to { name: name }.
let name = "Alice"let age = 30let user = User { name, email, age }Update with spread — explicit fields first, ..base last, explicit wins:
let updated = User { age: 31, ..user }Two types with identical fields are NOT interchangeable. User is not Product even if both have name: string.
Default Field Values
Section titled “Default Field Values”Fields with defaults can be omitted when constructing:
type Config = { baseUrl: string, timeout: number = 5000, retries: number = 3,}
let c = Config { baseUrl: "https://api.com" }// timeout is 5000, retries is 3Rules:
- Defaults must be compile-time constants or constructors (no function calls)
- Required fields (no default) must come before defaulted fields
Record Composition
Section titled “Record Composition”Include fields from other record types using spread syntax:
type BaseProps = { className: string, disabled: boolean,}
type ButtonProps = { ...BaseProps, onClick: () -> (), label: string,}// ButtonProps has: className, disabled, onClick, labelMultiple spreads are allowed:
type A = { x: number }type B = { y: string }type C = { ...A, ...B, z: boolean }Spreads work with generic types and typeof, including npm imports:
import { tv, VariantProps } from "tailwind-variants"
let cardVariants = tv({ base: "rounded-xl", variants: { padding: { sm: "p-4" } } })
type CardProps = { ...VariantProps<typeof cardVariants>, className: string,}Rules:
- Spread can reference a record type or a generic/foreign type
- Field name conflicts between spreads or with direct fields are compile errors
- The resulting type compiles to a TypeScript intersection
Tagged Sums
Section titled “Tagged Sums”Discriminated unions with nominal variants. The leading | is optional. Positional fields use ( ), named fields use { }:
type Color = | Red | Green | Blue | Custom { r: number, g: number, b: number }
type Shape = Circle(number) | Rect(number, number) | Pointfloe fmt keeps the whole declaration on one line when it fits within the 100-column line width; otherwise it splits every variant onto its own | line. Single-variant newtypes and short enums stay inline.
| at the top level of a type declaration always declares fresh constructors. If you want a structural string union instead, use OneOf<>.
Qualified Variants
Section titled “Qualified Variants”Use Type.Variant to qualify which sum a variant belongs to:
type Filter = All | Active | Completed
let f = Filter.Alllet g = Filter.ActivesetFilter(Filter.Completed)When two sums share a variant name, the compiler requires qualification:
type Color = Red | Green | Bluetype Light = Red | Yellow | Green
let c = Red// Error: variant `Red` is ambiguous — defined in both `Color` and `Light`// Help: use `Color.Red` or `Light.Red`
let c = Color.Red // OKlet l = Light.Red // OKUnambiguous variants can still be used bare. In match arms, bare variants always work because the type is known from the match subject:
match filter { All -> showAll(), Active -> showActive(), Completed -> showCompleted(),}Variant Constructors as Functions
Section titled “Variant Constructors as Functions”Non-unit variants (variants with fields) can be used as function values by referencing them without arguments:
type SaveError = | Validation { errors: Array<string> } | Api { message: string }
// Bare variant name becomes an arrow functionlet toValidation = Validation// Equivalent to: (errors) -> Validation(errors: errors)
// Qualified syntax works toolet toApi = SaveError.Api
// Most useful with higher-order functions like mapErr:result |> Result.mapErr(Validation)Unit variants (no fields) are values, not functions.
Result and Option
Section titled “Result and Option”Result and Option are built-in tagged sums:
// Equivalent to: type Option<T> = Some(T) | None// Equivalent to: type Result<T, E> = Ok(T) | Err(E)Result
Section titled “Result”For operations that can fail:
let result = Ok(42)let error = Err("something went wrong")Option
Section titled “Option”For values that may be absent:
let found = Some("hello")let missing = NoneSettable
Section titled “Settable”Settable<T> is a three-state type for partial updates. In a PATCH API, you need to distinguish between “set this field to a value”, “clear this field to null”, and “don’t touch this field”. TypeScript’s Partial<T> can’t tell the difference between “set to undefined” and “not provided”.
type Settable<T> = Value(T) | Clear | UnchangedUse it with default field values so callers only specify what they’re changing:
type UpdateUser = { name: Settable<string> = Unchanged, email: Settable<string> = Unchanged, avatar: Settable<string> = Unchanged,}
// Set name, clear avatar, leave email alonelet patch = UpdateUser { name: Value("Ryan"), avatar: Clear }What it compiles to
Section titled “What it compiles to”Settable fields have special codegen. Unchanged fields are omitted entirely from the output object:
| Floe | TypeScript output |
|---|---|
Value("Ryan") | "Ryan" |
Clear | null |
Unchanged | (key omitted) |
So UpdateUser { name: Value("Ryan"), avatar: Clear } compiles to { name: "Ryan", avatar: null } — no email key at all.
The ? Operator
Section titled “The ? Operator”Propagate errors concisely:
let getUsername(id: string) -> Result<string, Error> = { let user = fetchUser(id)? // returns Err early if it fails Ok(user.name)}Newtypes
Section titled “Newtypes”Single-variant wrappers that are distinct at compile time but erase at runtime:
type UserId = UserId(string)type PostId = PostId(string)
// Both strings at runtime, but can't be mixed up at compile timeThe constructor name typically matches the type name — that is the idiomatic form.
Opaque Types
Section titled “Opaque Types”Types where only the defining module can see the internal structure:
opaque type Email = Email(string)
// Only this module can construct/destructure Email valuesFunction-Type Aliases
Section titled “Function-Type Aliases”Name a function type to use it in records or generics. Parameter labels are optional documentation:
typealias Handler = (req: Request) -> Promise<Response>typealias Predicate<T> = (T) -> boolean
type Button = { label: string, onClick: () -> (), onSubmit: (form: FormData) -> (),}Labels never affect structural assignability — (x: Int) -> Int, (y: Int) -> Int, and (Int) -> Int are all the same type. Add labels when the name carries meaning (DDD-style workflow types, multi-param callbacks); skip them when the position is obvious.
Tuple Types
Section titled “Tuple Types”Anonymous lightweight product types:
let point: (number, number) = (10, 20)
let divmod(a: number, b: number) -> (number, number) = { (a / b, a % b)}
let (q, r) = divmod(10, 3)Tuples compile to TypeScript readonly tuples: (number, string) becomes readonly [number, string].
Structural TypeScript Types
Section titled “Structural TypeScript Types”When bridging to TypeScript libraries, two utility types cover the structural operators TS uses that have no nominal equivalent in Floe:
typealias HttpMethod = OneOf<"GET", "POST", "PUT", "DELETE">typealias CardProps = Intersect<VariantProps<typeof cardVariants>, { className: string }>OneOf<A, B, ...>compiles toA | B | ...Intersect<A, B, ...>compiles toA & B & ...
Alias existing TypeScript types with plain =:
typealias DivProps = ComponentProps<"div">typealias PartialUser = Partial<User>String-literal unions work with exhaustive matching:
let describe(method: HttpMethod) -> string = { match method { "GET" -> "fetching", "POST" -> "creating", "PUT" -> "updating", "DELETE" -> "removing", }}For your own data, prefer tagged sums (type Method = Get | Post). Reach for OneOf<> and Intersect<> when you need the structural shapes TypeScript libraries hand you.
Common Errors
Section titled “Common Errors”| Code | Trigger | Fix |
|---|---|---|
E201 | Bare string-literal union (type M = "a" | "b") | Use OneOf<"a", "b"> |
E202 | Inline record in a function signature | Name the type: type Arg = { ... } then let f(x: Arg) -> ... |
Differences from TypeScript
Section titled “Differences from TypeScript”| TypeScript | Floe equivalent |
|---|---|
any | unknown + narrowing |
null, undefined | Option<T> |
enum | Tagged sums |
interface | type |
"a" | "b" | OneOf<"a", "b"> |
A & B | Intersect<A, B> (or record spread) |
(x: T) => U (in types) | (T) -> U |