Esc
Start typing to search...

Types

Keel has a strong, static type system with powerful type inference. This chapter covers built-in types and how to define your own.

Built-in Types

Primitive Types

TypeDescriptionExample
Int64-bit signed integer42, -7
Float64-bit floating point3.14, -0.5
DecimalArbitrary-precision decimal42d, 3.14d
BoolBoolean valueTrue, False
StringText string"hello"
CharSingle character'a', 'z'
SymbolInterned identifier:north, :ok
UnitEmpty value (like void)()
let x = 42

let pi = 3.14

let active = True

let name = "Alice"

let letter = 'A'

name
Try it

Keel enforces strict type distinctions — Bool and Int are not interchangeable:

True + 1
Try it

Decimal Type

The Decimal type provides arbitrary-precision decimal arithmetic with up to 28 significant digits. Use it when exact decimal representation matters (e.g., financial calculations):

let price = 19.99d

let tax = 0.07d

let total = price + price * tax

total
Try it

Decimal literals use the d suffix: 42d, 3.14d, -0.001d. Standard arithmetic operators (+, -, *, /, %, ^) and comparison operators work with Decimal values. See the Decimal stdlib module for additional functions like rounding, parsing, and conversion.

Symbol Type

Symbols are lightweight, interned identifiers prefixed with :. They're useful as tags, keys, and labels — anywhere you'd use a short string but want a distinct type:

[:north, :south, :east]
Try it

Symbols support equality (==, !=) and pattern matching, but not ordering (<, >, etc.):

[:north == :north, :north == :south]
Try it
let dir = :north

case dir of
    :north -> "going up"
    :south -> "going down"
    _ -> "going sideways"
Try it

For names with spaces or special characters, use quoted syntax:

:"hello world"
Try it

Compound Types

-- List of integers
let numbers =
    [1, 2, 3]
    -- Tuple

let pair = (42, "answer")

-- Optional integer
let maybe = Just 5

numbers
Try it

Tuples, lists, and records support equality (==, !=). Two tuples are equal if they have the same length and each element pair is equal. Two lists are equal if they have the same length and each element pair is equal. Two records are equal if they have the same field names in the same order and each value pair is equal.

Bracket List Syntax

List types can also be written with bracket syntax:

let numbers : [Int] = [1, 2, 3]

let names : [String] = ["Alice", "Bob"]

let nested : [[Int]] = [[1, 2], [3, 4]]

nested
Try it

Both List Int and [Int] are equivalent — use whichever reads better in context.

Parenthesized Types for Grouping

Use parentheses to group compound types as a single type argument:

let x : Result (Maybe Int) String = Ok Just 5

let y : Maybe [Int] = Just [1, 2, 3]

let z : [Maybe Int] =
    [ Just 1
    , Nothing
    , Just 2
    ]

z
Try it

Parentheses are needed when a type argument is itself a compound type (like Maybe Int). Without them, Result String Maybe Int would be ambiguous. Simple type names don't need parentheses:

let x : Result { name : String, age : Int } String = Ok { name = "Alice", age = 30 }

let y : Maybe { name : String, age : Int } = Just { name = "Bob", age = 25 }

let z : [{ name : String, age : Int }] = [{ name = "Alice", age = 30 }]

z
Try it

Newtypes

type Name = BaseType creates a nominally distinct type — values of Name and BaseType are not interchangeable, even though they have the same runtime representation.

Construct a newtype value with the type name as a constructor:

type UserId = Int

let id = UserId 42

case id of
    UserId n -> n
Try it

Extract the inner value with pattern matching:

case id of
    UserId n -> n

Generic newtypes are supported:

type Validated a = a

let score : Validated Int = Validated 99

case score of
    Validated n -> n
Try it

Two newtypes over the same base type are mutually incompatible:

-- norun
-- expect-error: [Runtime] Type errors during compilation:
-- expect-error:   - Type mismatch: expected UserId, found PostId
-- expect-error:     hint: Function signature: UserId -> String
-- expect-error: Expected argument type: UserId, but got: PostId
-- UserId and PostId are distinct types — not interchangeable
type UserId = Int
type PostId = Int

fn greet : UserId -> String
fn greet x = "hello"

let pid = PostId 1

greet pid  -- Error: expected UserId, found PostId
Try it

The constructor and pattern match are identity at runtime — they exist only to satisfy the type checker. There is no performance cost.

Record newtypes

A newtype can wrap a record:

type Person = { name: String, age: Int }

let p = Person { name = "Alice", age = 30 }

case p of
    Person { name } -> name
Try it

Tuple newtypes

A newtype can wrap a tuple:

type Pair = (Int, String)

let p = Pair (1, "x")

case p of
    Pair (n, _) -> n
Try it

Type Aliases

type alias Name = Type creates a transparent synonym. The alias name and the underlying type are fully interchangeable at compile time — values flow between them without any constructor or pattern match. There is no runtime representation difference.

type alias UserId = Int

let id : UserId = 42
id + 1
Try it

Record aliases are the primary use case: give a long structural record type a short, reusable name.

type alias Person = { name : String, age : Int }

let p : Person = { name = "Alice", age = 30 }
p.age
Try it

A function accepting an open record type (one that uses .. to allow extra fields) accepts an aliased value that has additional fields beyond those required:

type alias Person = { name : String, age : Int }

fn greet : { name : String, .. } -> String
fn greet p = p.name

let alice : Person = { name = "Alice", age = 30 }
greet alice
Try it

Parameterized aliases

Aliases can take type parameters:

type alias Box a = { value : a }

let b : Box Int = { value = 42 }
b.value
Try it

Alias vs. newtype

An alias is transparent — a plain record satisfies PersonAlias and vice versa:

-- type alias: transparent — a plain record satisfies the annotation
type alias PersonAlias = { name : String, age : Int }

let a : PersonAlias = { name = "Alice", age = 30 }
a.name
Try it

A newtype (type Person = { name: String, age: Int }) is opaque — a plain record is not a Person. Use a newtype when you want the type checker to distinguish domain concepts; use an alias when you want a short name for a structural type without imposing any restriction.

Aliases in modules

type alias declarations inside a module are available in the module scope. Use type alias to name the module's public record shape so callers can annotate values without repeating the full structural type.

Nested aliases

An alias can reference another alias; the compiler expands them both before type-checking. There is no depth limit.

Newtypes vs single-variant enums

At runtime and in the type system, type UserId = Int and enum UserId = UserId(Int) are identical — both produce the same Enum value and support the same pattern matching. The distinction is intent:

  • Use type when you are naming a domain concept built on an existing type (UserId, Celsius, ValidatedEmail). The inner type is what matters; the name is the label.
  • Use enum when the variant name itself is meaningful, even with only one variant today — for example, because more variants are likely, or because the variant name represents a state or tag.
-- type: the Float is the value, Celsius is just the unit label
type Celsius = Float

-- enum: the variant name Loading is the value — no inner data matters
enum LoadingState = Loading | Loaded

Custom Types (Enums)

Define types with multiple variants using enum:

Simple Enums

enum Direction
    = North
    | South
    | East
    | West

let heading = Direction::North

heading
Try it

Multi-line format:

enum Direction
    = North
    | South
    | East
    | West

let heading = Direction::South

heading
Try it

Using Custom Types

enum Direction
    = North
    | South
    | East
    | West

let favorite : Direction = Direction::North

case favorite of
    Direction::North -> "Going up"
    Direction::South -> "Going down"
    Direction::East -> "Going right"
    Direction::West -> "Going left"
Try it

Variants with Data

Variants can carry data using parenthesized syntax:

enum Shape
    = Circle(Float)
    | Rectangle(Float, Float)

fn area : Shape -> Float
fn area shape =
    case shape of
        Shape::Circle r -> 3.14159 * r * r
        Shape::Rectangle w h -> w * h

area Shape::Circle 5.0  -- 78.54
Try it
enum Shape
    = Circle(Float)
    | Rectangle(Float, Float)

fn area : Shape -> Float
fn area shape =
    case shape of
        Shape::Circle r -> 3.14159 * r * r
        Shape::Rectangle w h -> w * h

area (Shape::Rectangle (4.0, 3.0))  -- 12.0
Try it

Single-argument variants also support space-separated construction (Shape::Circle 5.0), while multi-arg requires parentheses:

enum Shape
    = Circle(Float)
    | Rectangle(Float, Float)

Shape::Rectangle (10.0, 20.0)
Try it

Variants with Record Data

enum User
    = Guest
    | Member { name : String, id : Int }

let user = User::Member { name = "Alice", id = 42 }

case user of
    User::Guest -> "Anonymous visitor"
    User::Member { name, id } -> "User " ++ name
Try it

Variants with Value Labels

ValueLabel is a built-in type — always available without imports, just like Bool, Maybe, or Result.

The ValueLabel type lets you embed integer-to-string mappings directly in enum variants. Each variant carries a (Int, String) pair — the integer code and its human-readable label:

-- Define an enum with ValueLabel data
-- Construct and match on ValueLabel variants
enum Gender
    = Male(ValueLabel 1 "Male")
    | Female(ValueLabel 2 "Female")
    | Other(ValueLabel 3 "Other")

case Gender::Male of
    Gender::Male _ -> "matched male"
    Gender::Female _ -> "matched female"
    Gender::Other _ -> "matched other"
Try it

When you construct a ValueLabel variant (e.g., Gender::Male), the integer and label are auto-supplied from the type definition — you never write Gender::Male (ValueLabel 1 "Male") manually.

This is useful for survey data, coded variables, and any domain where values have both numeric codes and descriptive labels. You can access individual label data with the ValueLabel module, or extract the full mapping as a ValueLabelSet at compile time:

-- Generate a ValueLabelSet from a ValueLabel enum
import ValueLabelSet

enum Gender
    = Male(ValueLabel 1 "Male")
    | Female(ValueLabel 2 "Female")
    | Other(ValueLabel 3 "Other")

let labels = ValueLabelSet.fromType Gender

ValueLabelSet.size labels
Try it

ValueLabelSet.fromType is a compiler intrinsic — it reads the mappings from the type definition at compile time. The compiler enforces that:

  • All variants must have exactly one ValueLabel field (no mixing with plain variants)
  • Integer values must be unique across variants

See Data Contracts for using ValueLabel enums with DataFrame validation, and ValueLabelSet for the full API.

Maybe Type

Represents optional values:

-- norun
enum Maybe a = Just(a) | Nothing
  • Just a — contains a value
  • Nothing — no value
let present = Just 42

let absent = Nothing

case present of
    Just n -> "Got a value"
    Nothing -> "No value"
Try it

Result Type

Represents success or failure:

-- norun
enum Result a e = Ok(a) | Err(e)
  • Ok a — success with value
  • Err e — failure with error
let success = Ok 42

case success of
    Ok value -> "Success"
    Err msg -> "Error: " ++ msg
Try it
let failure = Err "something went wrong"

case failure of
    Ok value -> "Success"
    Err msg -> "Error: " ++ msg
Try it

Generic Types

Types can have type parameters:

enum Pair a b = Pair(a, b)

let p = Pair::Pair (1, "hello")

p
Try it
enum Tree a
    = Leaf(a)
    | Node(Tree a, a, Tree a)

let tree : Tree Int =
    Tree::Node (Tree::Leaf 1, 2, Tree::Node (Tree::Leaf 3, 4, Tree::Leaf 5))

tree
Try it

Record Types

Common mistake: Writing enum Person = { name: String, age: Int } is an error — enum declares sum types with named variants. Use an inline record type annotation ({ name: String, age: Int }), a transparent alias (type alias Person = { name: String, age: Int }), or an opaque newtype (type Person = { name: String, age: Int }) instead. The alias keeps the plain record and the named type interchangeable; the newtype does not.

Define structured data with named fields:

let user : { id : Int, name : String, email : String, active : Bool } =
    { id = 1
    , name = "Alice"
    , email = "alice@example.com"
    , active = True
    }

user.name ++ " <" ++ user.email ++ ">"
Try it

Records support structural equality (==, !=). Two records are equal if they have the same field names in the same order and all field values are equal.

Open Record Types

Record types can use .. to allow extra fields beyond those specified:

let p : { name : String, age : Int, .. } =
    { name = "Alice"
    , age = 30
    , email = "a@b.com"
    }

p.name
Try it

Open record types are particularly useful with DataFrames for schema validation:

import DataFrame

-- Open record type allows extra fields

let data : DataFrame { name : String, age : Int, .. } =
    DataFrame.fromRecords
        [ { name = "Alice", age = 30, email = "alice@example.com" }
        ]

DataFrame.columns data
Try it

Use closed record types (no ..) when you want exact schema matches, and open record types when you need flexibility.

Symbol Field Names

When field names don't conform to Keel's lowercase identifier syntax (e.g. uppercase or containing spaces), use symbol syntax in type annotations. This is most common with DataFrame schemas from external data sources (SPSS, Stata, Excel exports):

-- norun

-- Symbol field names in type annotations let you reference columns
-- whose names don't conform to lowercase identifier syntax.
-- This is most useful for DataFrame schemas from external data sources:

import DataFrame

-- Uppercase column names (e.g. from SPSS/Stata exports)
let df : DataFrame { :Name : String, :Age : Int, .. } =
    case DataFrame.readCsv "users.csv" of
        Ok d  -> d
        Err _ -> DataFrame.fromRecords []

-- Quoted field names for spaces or special characters
let df2 : DataFrame { :"Full Name" : String, :Score : Int, .. } =
    case DataFrame.readCsv "scores.csv" of
        Ok d  -> d
        Err _ -> DataFrame.fromRecords []
Try it

Recursive Types

Types can reference themselves:

enum MyList a = Nil | Cons(a, MyList a)

let nums =
    MyList::Cons (1, MyList::Cons (2, MyList::Cons (3, MyList::Nil)))

nums
Try it

Type Inference

Keel features automatic type inference, reducing the need for explicit annotations while maintaining type safety.

Let Bindings

Types are inferred from the assigned value:

let x = 42  -- Inferred as Int

let name = "Alice"  -- Inferred as String

let ratio = 3.14  -- Inferred as Float

let xs = [1, 2, 3]  -- Inferred as List Int

name
Try it

Add annotations when inference is ambiguous:

import List

let empty : [Int] = []  -- Needed: can't infer element type

List.length empty
Try it

Numeric Type Promotion

When mixing Int and Float, Int is promoted to Float:

let x = 1 + 2.5

let y = 3 * 1.5

x + y
Try it

Pattern Type Propagation

Types are propagated to variables in destructuring patterns:

-- Tuple destructuring
let pair = (1, "hello")

let (x, y) = pair  -- x: Int, y: String

y
Try it
-- Case expression patterns
case (42, "test") of
    (x, y) -> y
Try it
-- Lambda parameter patterns with context
(1, 2) |> |(a, b)| a + b
Try it

Context-Based Lambda Inference

Lambda parameter types can be inferred from context when the expected type is known:

-- Pipe operator provides context
5 |> |x| x + 1
Try it
-- Higher-order function context
import List

-- Higher-order function context
[1, 2, 3] |> List.map (|x| x * 2)

-- x inferred as Int from list context
Try it

Standalone lambdas without context require explicit annotations:

(|x : Int| x + 1) 1
Try it

Generic Function Results

Some functions return a generic type that can't be inferred from the arguments alone. In these cases, you must provide a type annotation on the let binding:

import Json

let data : Result { name : String } String = Json.parse "{\"name\": \"Alice\"}"

data
Try it

This applies to any function whose return type contains type variables that aren't determined by the input types (e.g., Json.parse : String -> Result a String — the a is unconstrained).

Best Practices

  1. Use newtypes for domain typestype UserId = Int prevents passing a PostId where a UserId is expected; the compiler catches the mistake
  2. Use type alias for structural reuse — when you have a long record type that appears in many annotations, type alias Row = { x: Int, y: String } gives it a short name without adding any type restriction
  3. Use enum for state machines and variants
  4. Keep types small — split large records into focused types
  5. Add type signatures to public functions
  6. Use Maybe for optional values instead of null
  7. Use Result for operations that can fail

Next Steps

Learn about pattern matching to work with your types.