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' |
Unit | Empty value (like void) | () |
let x: Int = 42
let pi: Float = 3.14
let price: Decimal = 19.99d
let active: Bool = True
let name: String = "Alice"
let letter: Char = 'A'
name
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 -- Exact: no floating-point rounding
total
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.
Keel enforces strict type distinctions — Bool and Int are not interchangeable:
True + 1 -- Error: type mismatch
Compound Types
-- List of integers
let numbers: List Int = [1, 2, 3]
-- Tuple
let pair = (42, "answer")
-- Optional integer
let maybe: Maybe Int = Just 5
numbers
Try itBracket 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]]
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 (List Int) = Just [1, 2, 3]
let z: List (Maybe Int) = [Just 1, Nothing, Just 2]
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 (including type aliases) don't need parentheses:
type alias Person = { name: String, age: Int }
let x: Result Person String = Ok { name = "Alice", age = 30 }
let y: Maybe Person = Just { name = "Bob", age = 25 }
let z: List Person = [{ name = "Alice", age = 30 }]
Type Aliases
Create alternative names for types:
type alias Name = String
type alias Age = Int
let userName: Name = "Bob"
let userAge: Age = 30
userName
Try itParameterized type aliases:
type alias Container a = { value: a }
let intBox: Container Int = { value = 42 }
intBox.value
Try itType aliases are fully resolved during type checking:
type alias UserId = Int
let x: UserId = 42 -- UserId resolves to Int
let y: Int = x -- Compatible: UserId is Int
x + 1 -- Arithmetic works: UserId is Int
Try itCustom Types (Enums)
Define types with multiple variants using type:
Simple Enums
type Direction = North | South | East | West
let heading = North
heading
Multi-line format:
type Direction
= North
| South
| East
| West
let heading = South
heading
Using Custom Types
type Direction = North | South | East | West
let favorite: Direction = North
case favorite of
North -> "Going up"
South -> "Going down"
East -> "Going right"
West -> "Going left"
Variants with Data
Variants can carry data using parenthesized syntax:
type Shape
= Circle(Float)
| Rectangle(Float, Float)
fn area : Shape -> Float
fn area shape = case shape of
Circle r -> 3.14159 * r * r
Rectangle w h -> w * h
area (Circle 5.0) -- 78.54
type Shape
= Circle(Float)
| Rectangle(Float, Float)
fn area : Shape -> Float
fn area shape = case shape of
Circle r -> 3.14159 * r * r
Rectangle w h -> w * h
area (Rectangle 4.0 3.0) -- 12.0
Single-argument variants also support space-separated construction:
type Shape = Circle(Float) | Rectangle(Float, Float)
Shape::Circle(5.0) -- parenthesized
Shape::Circle 5.0 -- space-separated (single arg only)
Shape::Rectangle(10.0, 20.0) -- multi-arg requires parentheses
Variants with Record Data
type User
= Guest
| Member { name: String, id: Int }
let user = Member { name = "Alice", id = 42 }
case user of
Guest -> "Anonymous visitor"
Member { name, id } -> "User " ++ name
Maybe Type
Represents optional values:
type Maybe a = Just a | Nothing
Just a— contains a valueNothing— no value
let present: Maybe Int = Just 42
let absent: Maybe Int = Nothing
case present of
Just n -> "Got a value"
Nothing -> "No value"
Try itResult Type
Represents success or failure:
type Result a e = Ok a | Err e
Ok a— success with valueErr e— failure with error
let success: Result Int String = Ok 42
case success of
Ok value -> "Success"
Err msg -> "Error: " ++ msg
let failure: Result Int String = Err "something went wrong"
case failure of
Ok value -> "Success"
Err msg -> "Error: " ++ msg
Generic Types
Types can have type parameters:
type Pair a b = Pair a b
let p = Pair 1 "hello"
p
type Tree a
= Leaf a
| Node (Tree a) a (Tree a)
let tree: Tree Int =
Node
(Leaf 1)
2
(Node (Leaf 3) 4 (Leaf 5))
tree
Record Types
Common mistake: Writing
type Person = { name: String, age: Int }is an error —typedeclares enums with variants. Usetype aliasfor record types.
Define structured data with named fields:
type alias User = {
id: Int,
name: String,
email: String,
active: Bool
}
let user: User = {
id = 1,
name = "Alice",
email = "alice@example.com",
active = True
}
user.name ++ " <" ++ user.email ++ ">"
Open Record Types
Record types can use .. to allow extra fields beyond those specified:
-- Closed record type (exact match required)
type alias Person = { name: String, age: Int }
-- Open record type (allows extra fields)
type alias MinPerson = { name: String, age: Int, .. }
Open record types are particularly useful with DataFrames for schema validation:
import DataFrame
-- Require minimum columns, allow extras
type alias MinSchema = { name: String, age: Int, .. }
let data: DataFrame MinSchema = DataFrame.readCsv "users.csv"
-- CSV can have extra columns like "email", "city" — they're allowed
Use closed record types (no ..) when you want exact schema matches, and open record types when you need flexibility.
Recursive Types
Types can reference themselves:
type MyList a = Nil | Cons a (MyList a)
let nums = Cons 1 (Cons 2 (Cons 3 Nil))
nums
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 itAdd annotations when inference is ambiguous:
let empty: List Int = [] -- Needed: can't infer element type
length empty
Numeric Type Promotion
When mixing Int and Float, Int is promoted to Float:
let x = 1 + 2.5 -- Float (Int promoted to Float)
let y = 3 * 1.5 -- Float
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 -- x inferred as Int
Try it-- Higher-order function context
[1, 2, 3] |> map (|x| x * 2) -- x inferred as Int from list context
Standalone lambdas without context require explicit annotations:
|x| x + 1 -- Error: requires type annotation
|x: Int| x + 1 -- OK: explicit annotation provided
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
-- Error: Generic function result requires a type annotation for variable 'data'
let data = Json.parse "{\"name\": \"Alice\"}"
-- OK: type annotation tells the compiler what to expect
let data: Result { name: String } String = Json.parse "{\"name\": \"Alice\"}"
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
- Define domain types —
UserIdis better thanInt - Use sum types for state machines and variants
- Keep types small — split large records into focused types
- Add type signatures to public functions
- Use Maybe for optional values instead of null
- Use Result for operations that can fail
Next Steps
Learn about pattern matching to work with your types.