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-name or has-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