Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Types

Keel is statically typed with full inference as the design target. In the current alpha, the checker catches core mismatches before your code runs. Where the type cannot be determined statically, the checker uses one of three internal fall-back markers — Unknown(InferenceLimitation), Unknown(ExternalDynamic), or Unknown(UnsupportedFeature) — which are distinct from the programmer-written dynamic annotation and are surfaced by keel check --strict; see the ROADMAP for current checker coverage.

Primitive types

TypeExampleNotes
int4264-bit integer
float3.1464-bit float
str"hello"UTF-8, supports interpolation
booltrue, false
nonenoneAbsence of value
duration5.minutesTime duration
count = 42          # inferred as int
name = "Keel"       # inferred as str
ratio = 3.14        # inferred as float
active = true       # inferred as bool

Enums

Enums define a closed set of variants. The compiler enforces exhaustive handling.

type Urgency = low | medium | high | critical

type Category = bug | feature | question | billing

Enum values are accessed by name:

u = high                    # Urgency.high
c = bug                     # Category.bug
label = Urgency.high        # explicit qualified access

Generic types

Type declarations can be parameterised over one or more type variables. The type checker substitutes the concrete arguments at each use site.

Generic structs:

use std/io

type Paginated[T] {
  items: list[T]
  page: int
  has_more: bool
}

task show_page(p: Paginated[str]) {
  io.show("{p.items.len()} item(s) on page {p.page}")
}

Multi-parameter generics:

type Pair[A, B] {
  first: A
  second: B
}

task t(p: Pair[str, int]) {
  a: str = p.first    # type-checked as str
  b: int = p.second   # type-checked as int
}

Generic aliases:

type Bag[T] = list[T]

task t(tags: Bag[str]) {
  n: int = tags.len()
}

Generic enums:

type Pair[A, B] =
  | both { first: A, second: B }
  | only_first { value: A }
  | only_second { value: B }

Variant names are registered for exhaustiveness checking. When you destructure a variant binding, the field type is resolved using the substituted type arguments:

use std/io

task t(p: Pair[str, int]) {
  when p {
    both { first, second } => {
      f: str = first    # type-checked as str
      s: int = second   # type-checked as int
    }
    only_first { value } => { io.show(value) }
    only_second { value } => { io.show("{value}") }
  }
}

Each destructured name must be a field the variant declares. Naming a field the variant does not have (a typo, say) is a compile-time error rather than a silent none binding.

Structs

Each declared struct type has a unique identity. Two types with the same fields are distinct types and are not interchangeable:

type Point  { x: int, y: int }
type Offset { x: int, y: int }

task move(p: Point) -> Point { p }

o: Offset = { x: 1, y: 2 }
move(o)                         # error — Offset is not assignable to Point
move({ x: 1, y: 2 })           # ok — anonymous literal is compatible with Point

An untyped struct literal { x: 1, y: 2 } is an anonymous shape. It is structurally compatible with any named struct type that has the required fields. Assign to a typed variable or pass to a typed parameter to tag it:

use std/email

type EmailInfo {
  sender: str
  subject: str
  body: str
  unread: bool
}

# Inline struct types in parameters
task triage(msg: {body: str, from: str}) -> Urgency {
  ai.classify(msg.body, as: Urgency) ?? Urgency.low
}

The checker verifies every required field is present. Extra fields on anonymous literals are allowed:

type Person { name: str, age: int }

task t() {
  p: Person = { name: "Alice" }                        # error: missing field `age`
  q: Person = { name: "Bob", age: 30 }                 # ok
  r: Person = { name: "Eve", age: 25, extra: true }    # ok — extras allowed
}

Impl dispatch and typed collections

impl methods are dispatched by the value’s type tag. A struct literal only acquires its tag at the first typed boundary it crosses. For a list of struct values, declare the list type so each element is promoted:

type Score { val: int }
impl Comparable for Score {
  task compare(self, other: Score) -> int { self.val - other.val }
}

task run() {
  # Without the annotation, elements stay as untagged maps and .sort() falls
  # back to primitive ordering instead of using Comparable.compare.
  scores: list[Score] = [{ val: 30 }, { val: 10 }, { val: 20 }]
  sorted = scores.sort()   # [10, 20, 30] — uses Comparable.compare
}

Spread-update

To create a modified copy of a struct (or map) without repeating every field, use the { ...base, field: new } syntax:

type Order { id: str, status: str, amount: float }

o: Order = { id: "ord-1", status: "pending", amount: 9.99 }
filled   = { ...o, status: "filled" }   # id and amount copied unchanged
copy     = { ...o }                     # full copy, no overrides

Rules:

  • The ...base spread must appear first, exactly once.
  • Zero or more field: value overrides follow, separated by commas or newlines.
  • Spreading a none value raises at runtime.

Struct base — override field names must exist in the base struct; unknown fields are a compile-time error (and a runtime error on dynamic paths). The result preserves the base’s type tag so impl dispatch continues to work.

Map base — any key may be added or overridden freely (like Python’s {**d, "k": v}); override values must match the map’s declared value type. The result is the same map[K, V] type.

m: map[str, int] = { "a": 1, "b": 2 }
m2 = { ...m, "c": 3 }   # adds key "c"; result is still map[str, int]

Spread-update is especially useful when updating one field of a struct or building configuration variants:

type Config { host: str, port: int, debug: bool }

base: Config = { host: "localhost", port: 8080, debug: false }
dev  = { ...base, debug: true }
prod = { ...dev, host: "api.example.com", debug: false }

Nullable types

Types are non-nullable by default. Append ? to allow none:

name: str       # field cannot be none
alias: str?     # field can be none
type Email { subject: str, from: str }
task t(email: Email?) {
  # Null-safe access
  subject = email?.subject           # str? — none if email is none

  # Null coalescing
  subject = email?.subject ?? "(no subject)"   # str — guaranteed non-none
}

The checker enforces the ? boundary at every assignment, return, and call site. Passing a nullable where a non-nullable is expected is a compile-time error — use ! to assert non-null (raises a plain Error at runtime if the value is none) or ?? to coalesce to a default.

use std/env

task t() {
  x: str = env.get("KEY")          # error: expected str, got str?
  y: str = env.get("KEY")!         # ok — raises Error if missing
  z: str = env.get("KEY") ?? ""    # ok — falls back to ""
}

Call sites are also checked — a nullable argument where a non-nullable parameter is declared is a type error:

task process(text: str) { ... }  # expects non-nullable str

val: str? = env.get("PROMPT")
process(val)          # error: task `process` arg `text`: expected str, got str?
process(val!)         # ok — null-assertion (raises if none)
process(val ?? "")    # ok — null coalescing

AI operations return nullable types when they can fail:

result = ai.classify(text, as: Urgency)   # Urgency? — might be none
safe = result ?? Urgency.medium            # Urgency — guaranteed

# Or supply the default inline:
safe = ai.classify(text, as: Urgency) ?? Urgency.medium   # Urgency

Collections

nums = [1, 2, 3]                         # list[int]
names = ["alice", "bob"]                  # list[str]
info = {name: "Zied", role: "builder"}   # map[str, str]

Map key types. The key type K in map[K, V] must be a hashable primitive: str, int, or bool. Other types are compile-time errors:

scores:  map[str,  int]  = {alice: 100, bob: 95}   # valid
lookup:  map[int,  str]  = {1: "one", 2: "two"}    # valid
flags:   map[bool, str]  = {true: "on"}            # valid

# bad: map[float, str]   — float is not hashable (NaN)
# bad: map[str?,  int]   — nullable key type
# bad: map[Point, str]   — struct keys require interface Hashable (v0.2)

Subscript access (list[i]): integer index, returns T. Out-of-bounds and negative indices are runtime errors — use len() to guard or try/catch when the index may be invalid:

items = [10, 20, 30]
v = items[1]   # int — 20
# items[99]    # runtime error: index 99 out of bounds

String subscript (str[i]) returns a single-character str by the same rules.

List properties:

PropertyReturnsDescription
.countintNumber of elements
.firstT?First element or none
.lastT?Last element or none
.is_emptyboolTrue if count == 0

Function types

Function types describe callable values. Write the parameter types in parentheses followed by -> and the return type:

type Handler      = (str) -> bool
type Reducer      = (str, int) -> str
type Predicate[T] = (T) -> bool   # generic function type

task t(pred: Predicate[str]) {
  ok: bool = pred("hello")
}

() -> none is not valid syntax — a function type’s return must be a named type. Omit the return annotation on tasks to indicate “returns nothing”.

Tuples and function types share the (...) syntax — if -> follows the closing paren it is a function type; otherwise it is a tuple.

Type conversions

port_str = "8080"
port = port_str.to_int() ?? 3000     # int — 8080 or default
ratio = 3.to_float() / 4.to_float()  # float — 0.75
label = Urgency.high.to_str()        # str — "high"

Conversions that can fail return nullable types (str.to_int()int?). Conversions that always succeed return non-nullable (int.to_str()str).

Numeric value methods

int and float values expose four built-in methods. The return type always matches the receiver — calling a method on an int returns an int, and calling it on a float returns a float.

MethodReturnsNotes
.abs()same typeAbsolute value
.floor()same typeRound toward −∞; no-op on int
.ceil()same typeRound toward +∞; no-op on int
.round()same typeRound to nearest; no-op on int
price = -3.75
price.abs()           # 3.75
price.abs().ceil()    # 4.0  — methods chain naturally
count = -5
count.abs()           # 5    — int stays int
3.7.floor()           # 3.0
3.2.ceil()            # 4.0
3.5.round()           # 4.0

Duration literals

a = 5.seconds
b = 30.minutes
c = 2.hours
d = 1.day
e = 7.days

# Short forms also work
f = 30.sec
g = 1.min
h = 2.hr

Type coercions — as T

expr as T coerces the value at runtime. Unsupported conversions raise a runtime error.

FromToResult
intfloatWidens: 5 as float5.0
floatintTruncates toward zero: 1.9 as int1
int / float / boolstrDisplay string: 42 as str"42"
strintParses; raises if not a valid integer
strfloatParses; raises if not a valid float
strbool"true"true, "false"false; raises otherwise
UuidstrHyphenated string: "f47ac10b-..."
strUuidValidates UUID format; raises if invalid
dynamicanyPass-through — used with ai.prompt(...) as T and json.parse
noneanyRaises
1 as float          # 1.0
1.7 as int          # 1  (truncated, not rounded)
-1.7 as int         # -1
42 as str           # "42"
"3.14" as float     # 3.14
"99" as int         # 99

"abc" as int        # raises: cannot cast "abc" to int
none as int         # raises: cannot cast none to int

typeof(x)

The built-in function typeof(x) — always in scope, no import needed — returns the runtime type name as a str. For struct and enum values it returns the declared type name, not the generic "struct" or "enum" tag.

typeof(42)          # "int"
typeof(3.14)        # "float"
typeof("hello")     # "str"
typeof(true)        # "bool"
typeof(none)        # "none"
typeof([1, 2, 3])   # "list"

type Point { x: int, y: int }
p: Point = { x: 1, y: 2 }
typeof(p)           # "Point"

type Color = red | green | blue
c: Color = Color.red
typeof(c)           # "Color"

Type-mismatch diagnostics

When a let binding has an explicit type annotation and the assigned value does not match, keel check underlines the annotation — not the whole statement — so you can see at a glance which declared type is wrong:

error: `n`: expected int, got str
  --> example.keel:2:5
   |
 2 |   n: int = "hello"
   |      ^^^  — expected int here

This precision is available for all scalar, struct, and nullable mismatches on annotated let bindings.