Type System
Kit features a powerful static type system based on Hindley-Milner type inference. This means you get the safety of static typing without having to write type annotations—the compiler figures out types automatically.
Type Inference
Kit uses Hindley-Milner type inference, the same approach used by languages like Haskell, OCaml, and ML. The type checker analyzes your code and automatically deduces the types of all expressions, functions, and values.
# No type annotations needed
x = 42 # inferred as Int
name = "Kit" # inferred as String
double = fn(x) => x * 2 # inferred as Int -> Int
# The compiler knows all types automatically
result = double 21 # Int
You can use kit check to see the inferred types without running your program:
kit check my-program.kit
Inspecting Types at Runtime
Kit provides two built-in functions for inspecting types:
type-of returns the type of a value as a string:
type-of 42 # "Int"
type-of "hello" # "String"
type-of [1, 2, 3] # "List"
type-of {x: 1, y: 2} # "Record"
double = fn(x) => x * 2
type-of double # "Function"
type-sig returns the full type signature as a string:
type-sig 42 # "Int"
type-sig [1, 2, 3] # "List Int"
type-sig {name: "Kit"} # "{name: String}"
double = fn(x) => x * 2
type-sig double # "Int -> Int"
map-fn = fn(f, xs) => map f xs
type-sig map-fn # "(a -> b) -> List a -> List b"
Basic Types
Kit provides several fundamental types:
Int
Integer numbers, both positive and negative:
count = 42
temperature = -5
big = 1000000
Float
Floating-point numbers for decimal values:
price = 19.99
ratio = 0.5
celsius = -3.5
String
Text values with support for interpolation:
name = "Kit"
greeting = "Hello, ${name}!"
multiline = <<~TEXT
This is a
multiline string
TEXT
Bool
Boolean values for logic:
active? = true
completed? = false
is-even? = fn(n) => n % 2 == 0
Char
Unicode code points for individual characters:
# Character literals use single quotes
letter = 'a'
digit = '9'
emoji = '🚀'
# Convert between Char and code point
Char.to-code-point 'A' # => 65
Char.from-code-point 65 # => Some 'A'
# Character classification
Char.is-alpha? 'a' # => true
Char.is-digit? '5' # => true
Char.is-whitespace? ' ' # => true
Char.is-uppercase? 'A' # => true
# Case conversion
Char.to-uppercase 'a' # => 'A'
Char.to-lowercase 'Z' # => 'z'
# String to/from Char list
String.chars "hello" # => ['h', 'e', 'l', 'l', 'o']
String.from-chars ['h', 'i'] # => "hi"
Numeric Literals
Kit supports type suffixes on numeric literals to specify exact numeric types:
| Suffix | Type | Example | Description |
|---|---|---|---|
f or F |
Float32 | 3.14f |
32-bit floating point |
L |
Int64 | 1000L |
64-bit signed integer |
u |
Uint32 | 42u |
32-bit unsigned integer |
ul |
Uint64 | 42ul |
64-bit unsigned integer |
M or m |
Decimal | 19.99M |
Arbitrary-precision decimal |
I |
BigInt | 9999999999999I |
Arbitrary-precision integer |
# Default types (no suffix)
int-val = 42 # Int (32-bit signed)
float-val = 3.14 # Float (64-bit)
# Explicit type suffixes
small-float = 3.14f # Float32
big-int = 9000000000L # Int64
unsigned = 255u # Uint32
big-unsigned = 1000ul # Uint64
money = 19.99M # Decimal (precise)
huge = 123456789012345I # BigInt
Alternative Bases
Kit supports hexadecimal, binary, and octal literals using standard prefixes:
| Prefix | Base | Example | Decimal Value |
|---|---|---|---|
0x or 0X |
Hexadecimal (16) | 0xFF |
255 |
0b or 0B |
Binary (2) | 0b1010 |
10 |
0o or 0O |
Octal (8) | 0o777 |
511 |
# Hexadecimal literals
red = 0xFF0000 # 16711680
byte-max = 0xFF # 255
# Binary literals
flags = 0b10101010 # 170
mask = 0b11110000 # 240
# Octal literals
permissions = 0o755 # 493 (rwxr-xr-x)
mode = 0o644 # 420 (rw-r--r--)
# Alternative bases can combine with type suffixes
hex-long = 0xFFFFFFFFl # Int64
binary-unsigned = 0b1111u # Uint32
Function Types
Functions have types that describe their input and output. The notation a -> b means "a
function that takes an a and returns a b".
# Int -> Int
increment = fn(x) => x + 1
# String -> Int
string-length = fn(s) => String.length s
# Int -> Int -> Int (takes two Ints, returns an Int)
sum = fn(a, b) => a + b
# (Int -> Int) -> Int -> Int (takes a function and an Int)
apply-twice = fn(f, x) => f (f x)
Functions in Kit are automatically curried. This means a function that takes multiple arguments can be partially applied:
sum = fn(a, b) => a + b
# Partially apply sum to create a new function
add5 = sum 5 # Int -> Int
println (add5 10) # => 15
println (add5 100) # => 105
List Types
Lists are homogeneous collections—all elements must have the same type. The type
List a means "a list of values of type a".
# List Int
numbers = [1, 2, 3, 4, 5]
# List String
names = ["Alice", "Bob", "Carol"]
# List (List Int)
matrix = [[1, 2], [3, 4], [5, 6]]
# Empty lists need context to determine their type
empty = [] # Type inferred from usage
Functions that work with lists are polymorphic over the element type:
# List a -> Int
length [1, 2, 3] # => 3
length ["a", "b"] # => 2
# (a -> b) -> List a -> List b
map (fn(x) => x * 2) [1, 2, 3] # => [2, 4, 6]
Tuple Types
Tuples are fixed-size collections that can hold values of different types. The type
(a, b) represents a pair, (a, b, c) a triple, and so on.
# (String, Int)
person = ("Alice", 30)
# (Int, Int, Int)
rgb = (255, 128, 0)
# (String, (Int, Int))
point = ("origin", (0, 0))
# Pattern match to extract values
greet = fn(person) =>
match person
| (name, age) -> "${name} is ${age} years old"
println (greet person) # => "Alice is 30 years old"
Record Types
Records are collections of named fields. Each field has a name and a type. The type
{name: String, age: Int} describes a record with those specific fields.
# {name: String, age: Int, active?: Bool}
user = {
name: "Alice",
age: 30,
active?: true
}
# Access fields with dot notation
println user.name # => Alice
println user.age # => 30
# Nested records
employee = {
name: "Bob",
position: "Engineer",
contact: {
email: "bob@example.com",
phone: "555-1234"
}
}
println employee.contact.email # => bob@example.com
Records can be pattern matched:
describe-user = fn(user) =>
match user
| {name: n, age: a} -> "${n} is ${a} years old"
full-name = fn(person) =>
match person
| {first: f, last: l} -> "${f} ${l}"
Row Polymorphism
By default, Kit records are closed—they require exact field matches. This catches
accidental extra fields at compile time. However, you can opt-in to open records
using the ... syntax, allowing functions to accept records with additional fields.
Closed Records (Default)
Closed records require exact field matches. Passing a record with extra fields is a type error:
# Function expects exactly {name: String}
greet-closed = fn(person: {name: String}) =>
"Hello, " ++ person.name
greet-closed({name: "Kit"}) # OK
greet-closed({name: "Kit", age: 1}) # Type error: extra field 'age'
Open Records (Opt-in with ...)
Add ... to a record type to make it open. Open records accept any record
that has at least the specified fields:
# Function accepts record with name field plus any extra fields
greet = fn(person: {name: String, ...}) =>
"Hello, " ++ person.name
greet({name: "Alice"}) # OK
greet({name: "Bob", age: 30}) # OK - extra field allowed
greet({name: "Carol", email: "carol@test.com"}) # OK - different extra field
Empty Open Record
An empty open record {...} accepts any record, regardless of its fields:
# Accept any record
accept-any = fn(r: {...}) => "got a record"
accept-any({x: 1}) # OK
accept-any({name: "test", age: 42}) # OK
accept-any({a: 1, b: 2, c: 3}) # OK
Use Cases
Row polymorphism is useful for:
- Generic record utilities: Functions like
get-nameorhas-id?that work on any record with the required fields - Middleware/decorator patterns: Functions that process records without caring about all fields
- Database/API flexibility: Query results with varying shapes but shared common fields
# Generic function that works with any "named" record
get-name = fn(r: {name: String, ...}) => r.name
# Works with different record types
user = {name: "Alice", age: 30, role: "admin"}
product = {name: "Widget", price: 9.99, stock: 100}
println (get-name user) # => Alice
println (get-name product) # => Widget
# In pipelines
items = [{name: "A", x: 1}, {name: "B", y: 2}]
names = items |>> List.map get-name
# => ["A", "B"]
Algebraic Data Types
Algebraic Data Types (ADTs) let you define custom types with multiple variants. Use the
type keyword to create them.
Simple Enumerations
Define a type with a fixed set of values:
type Color = Red | Green | Blue | Yellow
favorite = Blue
to-hex = fn(color) =>
match color
| Red -> "#FF0000"
| Green -> "#00FF00"
| Blue -> "#0000FF"
| Yellow -> "#FFFF00"
println (to-hex favorite) # => #0000FF
Variants with Data
Variants can carry values:
type Shape =
| Circle Float
| Rectangle Float Float
| Triangle Float Float
area = fn(shape) =>
match shape
| Circle r -> 3.14159 * r * r
| Rectangle w h -> w * h
| Triangle b h -> 0.5 * b * h
shapes = [
Circle 5.0,
Rectangle 4.0 6.0,
Triangle 3.0 8.0
]
total-area = shapes
|> map area
|> fold (fn(acc, a) => acc + a) 0.0
println total-area # => 114.54...
Built-in ADTs: Option and Result
Kit provides two essential ADTs for handling optional values and errors. These are built-in types—you use them directly without defining them.
Option
Represents a value that may or may not exist:
# Option is defined as: Some a | None
# Use it directly—don't redefine it
find-positive = fn(list) =>
match list
| [] -> None
| [x | rest] ->
if x > 0 then
Some x
else
find-positive rest
found = find-positive [-1, -2, 3, 4]
match found
| Some value -> println "Found: ${value}"
| None -> println "Not found"
The Option module provides utility functions:
# Check if Some or None
Option.is-some? (Some 42) # => true
Option.is-none? None # => true
# Transform the value inside Option
Option.map (fn(x) => x * 2) (Some 5) # => Some 10
Option.map (fn(x) => x * 2) None # => None
# Chain operations that return Option
Option.and-then (fn(x) => Some (x + 1)) (Some 5) # => Some 6
# Extract value (panics if None)
Option.unwrap (Some 42) # => 42
# Extract with default value
Option.unwrap-or 0 (Some 42) # => 42
Option.unwrap-or 0 None # => 0
Result
Represents success or failure with an error value:
# Result is defined as: Ok a | Err e
# Use it directly—don't redefine it
safe-divide = fn(x, y) =>
if y == 0 then
Err "Division by zero"
else
Ok (x / y)
compute = fn(a, b) =>
match safe-divide a b
| Ok value -> "Result: ${value}"
| Err msg -> "Error: ${msg}"
The Result module provides utility functions:
# Check if Ok or Err
Result.is-ok? (Ok 42) # => true
Result.is-err? (Err "oops") # => true
# Transform the Ok value
Result.map (fn(x) => x * 2) (Ok 5) # => Ok 10
Result.map (fn(x) => x * 2) (Err "e") # => Err "e"
# Transform the Err value
Result.map-err String.to-upper (Err "oops") # => Err "OOPS"
# Chain operations that return Result
Result.and-then (fn(x) => Ok (x + 1)) (Ok 5) # => Ok 6
# Extract value (panics if Err)
Result.unwrap (Ok 42) # => 42
# Extract with default value
Result.unwrap-or 0 (Ok 42) # => 42
Result.unwrap-or 0 (Err "e") # => 0
See the Error Handling guide for more on using
Option and Result.
Type Aliases
Create readable names for complex types using type aliases. While Kit's syntax doesn't currently have explicit type alias syntax, you can document type shapes in comments:
# Type: Point = (Float, Float)
origin = (0.0, 0.0)
# Type: User = {id: Int, name: String, email: String}
create-user = fn(id, name, email) =>
{id: id, name: name, email: email}
# Functions can document their type signatures
# distance : Point -> Point -> Float
distance = fn(p1, p2) =>
match (p1, p2)
| ((x1, y1), (x2, y2)) ->
dx = x2 - x1
dy = y2 - y1
Float.sqrt (dx * dx + dy * dy)
Polymorphism
Polymorphic functions work with multiple types using type variables. Type variables are conventionally
named a, b, c, etc.
Simple Polymorphism
The identity function works with any type:
# identity : a -> a
identity = fn(x) => x
println (identity 42) # => 42 (Int -> Int)
println (identity "hello") # => hello (String -> String)
println (identity [1, 2]) # => [1, 2] (List Int -> List Int)
Constrained Polymorphism
Functions can be polymorphic over container types:
# get-first : List a -> Option a
get-first = fn(list) =>
match list
| [] -> None
| [x | _] -> Some x
# my-map : (a -> b) -> List a -> List b
my-map = fn(f, list) =>
match list
| [] -> []
| [x | rest] -> cons (f x) (my-map f rest)
# Works with any types that match the structure
println (my-map (fn(x) => x * 2) [1, 2, 3])
# => [2, 4, 6]
println (my-map String.length ["a", "ab", "abc"])
# => [1, 2, 3]
Higher-Order Polymorphism
Functions can take polymorphic functions as arguments:
# apply : (a -> b) -> a -> b
apply = fn(f, x) => f x
# compose : (b -> c) -> (a -> b) -> a -> c
compose = fn(f, g, x) => f (g x)
add1 = fn(x) => x + 1
double = fn(x) => x * 2
add1-then-double = compose double add1
println (add1-then-double 5) # => 12
Type Safety
Kit's type system catches many errors at compile time. The compiler will reject programs that have type mismatches:
# This will cause a type error:
bad = 42 + "hello"
# Error: Cannot add Int and String
# This will cause a type error:
get-first = fn(list) => head list
result = get-first 42
# Error: Expected List, but got Int
# This will cause a type error:
mixed = [1, "two", 3]
# Error: List elements must all have the same type
Type Checking
Use kit check to verify your program's types without running it. This is useful for
catching errors early during development:
# my-program.kit
process = fn(items) =>
items
|> filter (fn(x) => x > 0)
|> map (fn(x) => x * 2)
|> fold (fn(acc, x) => acc + x) 0
result = process [1, -2, 3, -4, 5]
$ kit check my-program.kit
Type checking successful
process : List Int -> Int
Refinement Types
Refinement types allow you to constrain a type with a predicate. They express properties that values must satisfy beyond their base type.
Syntax
A refinement type uses the syntax {binding: BaseType | predicate}:
# A positive integer
type PositiveInt = {x: Int | x > 0}
# A non-empty string
type NonEmptyString = {s: String | String.length s > 0# A valid percentage (0-100)
type Percentage = {p: Float | p >= 0.0 && p <= 100.0}
Using Refinement Types
When you assign a value to a refined type, the predicate is checked at runtime:
type PositiveInt = {x: Int | x > 0}
# This succeeds - 42 is positive
count: PositiveInt = 42
# This fails at runtime - -5 is not positive
bad: PositiveInt = -5
# Error: Refinement predicate failed: x > 0
Refinement Types with Functions
Refinement types are especially useful for function parameters:
type NonZero = {n: Int | n != 0}
# The type system ensures divisor is never zero
safe-divide = fn(dividend: Int, divisor: NonZero) =>
dividend / divisor
# Usage
result = safe-divide 10 2 # => 5
result = safe-divide 10 0 # Fails: predicate n != 0
Refinement Types vs Contracts
Both refinement types and contracts validate conditions, but they serve different purposes:
| Feature | Refinement Types | Contracts (@pre/@post) |
|---|---|---|
| Scope | Single value/parameter | Entire function |
| Reusability | Reusable type alias | Per-function definition |
| Best for | Domain types (PositiveInt, Email) | Function-specific invariants |
Next Steps
Now that you understand Kit's type system, explore:
- Functions - Learn about function definitions, closures, and currying
- Pattern Matching - Master destructuring and conditional logic
- List Module - Explore polymorphic list operations
- Language Tour - See types in action with examples