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
| Type | Description | Example |
|---|---|---|
Int | 64-bit signed integer | 42, -7 |
Float | 64-bit floating point | 3.14, -0.5 |
Decimal | Arbitrary-precision decimal | 42d, 3.14d |
Bool | Boolean value | True, False |
String | Text string | "hello" |
Char | Single character | 'a', 'z' |
Symbol | Interned identifier | :north, :ok |
Unit | Empty value (like void) | () |
let x = 42
let pi = 3.14
let active = True
let name = "Alice"
let letter = 'A'
name
Try itKeel enforces strict type distinctions — Bool and Int are not interchangeable:
True + 1
Try itDecimal 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 itDecimal 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 itSymbols support equality (==, !=) and pattern matching, but not ordering (<, >, etc.):
[:north == :north, :north == :south]
Try itlet dir = :north
case dir of
:north -> "going up"
:south -> "going down"
_ -> "going sideways"
Try itFor names with spaces or special characters, use quoted syntax:
:"hello world"
Try itCompound Types
-- List of integers
let numbers =
[1, 2, 3]
-- Tuple
let pair = (42, "answer")
-- Optional integer
let maybe = Just 5
numbers
Try itTuples, 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 itBoth 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 itParentheses 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 itNewtypes
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 itExtract 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 itTwo 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 itThe 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 itTuple newtypes
A newtype can wrap a tuple:
type Pair = (Int, String)
let p = Pair (1, "x")
case p of
Pair (n, _) -> n
Try itType 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 itRecord 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 itA 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 itParameterized aliases
Aliases can take type parameters:
type alias Box a = { value : a }
let b : Box Int = { value = 42 }
b.value
Try itAlias 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 itA 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
typewhen 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
enumwhen 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 itMulti-line format:
enum Direction
= North
| South
| East
| West
let heading = Direction::South
heading
Try itUsing 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 itVariants 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 itenum 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 itSingle-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 itVariants 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 itVariants 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 itWhen 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 itValueLabelSet.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
ValueLabelfield (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 valueNothing— no value
let present = Just 42
let absent = Nothing
case present of
Just n -> "Got a value"
Nothing -> "No value"
Try itResult Type
Represents success or failure:
-- norun
enum Result a e = Ok(a) | Err(e)
Ok a— success with valueErr e— failure with error
let success = Ok 42
case success of
Ok value -> "Success"
Err msg -> "Error: " ++ msg
Try itlet failure = Err "something went wrong"
case failure of
Ok value -> "Success"
Err msg -> "Error: " ++ msg
Try itGeneric Types
Types can have type parameters:
enum Pair a b = Pair(a, b)
let p = Pair::Pair (1, "hello")
p
Try itenum 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 itRecord Types
Common mistake: Writing
enum Person = { name: String, age: Int }is an error —enumdeclares 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 itRecords 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 itOpen 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 itUse 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 itRecursive 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 itType 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 itAdd annotations when inference is ambiguous:
import List
let empty : [Int] = [] -- Needed: can't infer element type
List.length empty
Try itNumeric 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 itPattern 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 itContext-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 itStandalone lambdas without context require explicit annotations:
(|x : Int| x + 1) 1
Try itGeneric 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 itThis 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
- Use newtypes for domain types —
type UserId = Intprevents passing aPostIdwhere aUserIdis expected; the compiler catches the mistake - Use
type aliasfor 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 - Use
enumfor state machines and variants - Keep types small — split large records into focused types
- Add type signatures to public functions
- Use
Maybefor optional values instead of null - Use
Resultfor operations that can fail
Next Steps
Learn about pattern matching to work with your types.