Types
Primitives
Section titled “Primitives”const name: string = "Alice"const age: number = 30const active: boolean = trueRecord Types
Section titled “Record Types”type User { name: string, email: string, age: number,}Construct records with the type name:
const user = User(name: "Alice", email: "a@b.com", age: 30)Update with spread:
const updated = User(..user, age: 31)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,}
const 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 trusted { tv, VariantProps } from "tailwind-variants"
const 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
Union Types
Section titled “Union Types”Discriminated unions with variants. 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}Qualified Variants
Section titled “Qualified Variants”Use Type.Variant to qualify which union a variant belongs to:
type Filter { | All | Active | Completed }
const f = Filter.Allconst g = Filter.ActivesetFilter(Filter.Completed)When two unions share a variant name, the compiler requires qualification:
type Color { | Red | Green | Blue }type Light { | Red | Yellow | Green }
const c = Red// Error: variant `Red` is ambiguous — defined in both `Color` and `Light`// Help: use `Color.Red` or `Light.Red`
const c = Color.Red // OKconst 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 functionconst toValidation = Validation// Equivalent to: fn(errors) Validation(errors: errors)
// Qualified syntax works tooconst toApi = SaveError.Api
// Most useful with higher-order functions like mapErr:result |> Result.mapErr(Validation)// Instead of: result |> Result.mapErr(fn(e) Validation(e))Unit variants (no fields) are values, not functions.
Result and Option
Section titled “Result and Option”Result and Option are built-in union types with positional variants:
// 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:
const result = Ok(42)const error = Err("something went wrong")Option
Section titled “Option”For values that may be absent:
const found = Some("hello")const missing = NoneSettable
Section titled “Settable”Settable<T> is a three-state type for partial updates. This is the problem it solves: 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 aloneconst 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.
Real-world example: PATCH endpoint
Section titled “Real-world example: PATCH endpoint”fn updateProfile(id: string, patch: UpdateUser) -> Result<User, ApiError> { const response = await Http.put("/api/users/{id}", patch)? response |> Http.json? |> parse<User>}
// Only update what changedupdateProfile("123", UpdateUser( name: Value("New Name"),))// Sends: { name: "New Name" } — email and avatar untouchedComparison with TypeScript
Section titled “Comparison with TypeScript”| Approach | ”set to value" | "clear to null" | "don’t change” |
|---|---|---|---|
TS Partial<T> | { name: "x" } | { name: null } | { } or { name: undefined } (ambiguous!) |
Floe Settable<T> | Value("x") | Clear | Unchanged (omitted from output) |
The ? Operator
Section titled “The ? Operator”Propagate errors concisely:
fn getUsername(id: string) -> Result<string, Error> { const 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(string)type PostId(string)
// Both strings at runtime, but can't be mixed up at compile timeOpaque Types
Section titled “Opaque Types”Types where only the defining module can see the internal structure:
opaque type Email { string }
// Only this module can construct/destructure Email valuesTuple Types
Section titled “Tuple Types”Anonymous lightweight product types:
const point: (number, number) = (10, 20)
fn divmod(a: number, b: number) -> (number, number) { (a / b, a % b)}
const (q, r) = divmod(10, 3)Tuples compile to TypeScript readonly tuples: (number, string) becomes readonly [number, string].
TypeScript Bridge Types
Section titled “TypeScript Bridge Types”When working with npm libraries, use type Name = ... to alias existing TypeScript types:
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE"type DivProps = ComponentProps<"div">type CardProps = VariantProps<typeof cardVariants> & { className: string }String literal unions work with exhaustive matching:
fn describe(method: HttpMethod) -> string { match method { "GET" -> "fetching", "POST" -> "creating", "PUT" -> "updating", "DELETE" -> "removing", }}For your own data, prefer union types (type Method { | Get | Post }) over string literals. Use = only when bridging to TypeScript libraries.
Differences from TypeScript
Section titled “Differences from TypeScript”| TypeScript | Floe equivalent |
|---|---|
any | unknown + narrowing |
null, undefined | Option<T> |
enum | Union types |
interface | type |