Skip to content

Types

let name: string = "Alice"
let age: number = 30
let active: boolean = true

Floe has two type-declaration keywords:

  • type declares a nominal type — a brand-new identity distinct from anything else. Use it for records, tagged sums, and newtypes.
  • typealias names 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:

RHStypetypealias
{ field: T, ... }Nominal recordStructural record alias
A | B | ... constructorsTagged sumError — tagged unions need nominal identity
Name(T)NewtypeError — newtypes need nominal identity
(T, ...) -> RetError — function types have no nominal identityFunction-type alias
A & BError — intersections are structuralStructural intersection
typeof valueError — typeof is structuralStructural typeof alias
Partial<T> / ReturnType<...> / any genericError — generic applications are structuralTS 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.

type User = {
name: string,
email: string,
age: number,
}

Construct records with brace form, Type { field: value, ... }:

let user = User { name: "Alice", email: "[email protected]", age: 30 }

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 email = "[email protected]"
let age = 30
let 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.

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 3

Rules:

  • Defaults must be compile-time constants or constructors (no function calls)
  • Required fields (no default) must come before defaulted fields

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, label

Multiple 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

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) | Point

floe 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<>.

Use Type.Variant to qualify which sum a variant belongs to:

type Filter = All | Active | Completed
let f = Filter.All
let g = Filter.Active
setFilter(Filter.Completed)

When two sums share a variant name, the compiler requires qualification:

type Color = Red | Green | Blue
type 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 // OK
let l = Light.Red // OK

Unambiguous 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(),
}

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 function
let toValidation = Validation
// Equivalent to: (errors) -> Validation(errors: errors)
// Qualified syntax works too
let 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 are built-in tagged sums:

// Equivalent to: type Option<T> = Some(T) | None
// Equivalent to: type Result<T, E> = Ok(T) | Err(E)

For operations that can fail:

let result = Ok(42)
let error = Err("something went wrong")

For values that may be absent:

let found = Some("hello")
let missing = None

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 | Unchanged

Use 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 alone
let patch = UpdateUser { name: Value("Ryan"), avatar: Clear }

Settable fields have special codegen. Unchanged fields are omitted entirely from the output object:

FloeTypeScript output
Value("Ryan")"Ryan"
Clearnull
Unchanged(key omitted)

So UpdateUser { name: Value("Ryan"), avatar: Clear } compiles to { name: "Ryan", avatar: null } — no email key at all.

Propagate errors concisely:

let getUsername(id: string) -> Result<string, Error> = {
let user = fetchUser(id)? // returns Err early if it fails
Ok(user.name)
}

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 time

The constructor name typically matches the type name — that is the idiomatic form.

Types where only the defining module can see the internal structure:

opaque type Email = Email(string)
// Only this module can construct/destructure Email values

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.

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].

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 to A | B | ...
  • Intersect<A, B, ...> compiles to A & 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.

CodeTriggerFix
E201Bare string-literal union (type M = "a" | "b")Use OneOf<"a", "b">
E202Inline record in a function signatureName the type: type Arg = { ... } then let f(x: Arg) -> ...
TypeScriptFloe equivalent
anyunknown + narrowing
null, undefinedOption<T>
enumTagged sums
interfacetype
"a" | "b"OneOf<"a", "b">
A & BIntersect<A, B> (or record spread)
(x: T) => U (in types)(T) -> U