Type System

Wippy includes a gradual type system with flow-sensitive checking. Types are non-nullable by default.

Primitives

local n: number = 3.14
local i: integer = 42         -- integer is subtype of number
local s: string = "hello"
local b: boolean = true
local a: any = "anything"     -- explicit dynamic (opt-out of checking)
local u: unknown = something  -- must narrow before use

any vs unknown

-- any: opt-out of type checking
local a: any = get_data()
a.foo.bar.baz()              -- no error, may crash at runtime

-- unknown: safe unknown, must narrow before use
local u: unknown = get_data()
u.foo                        -- ERROR: cannot access property of unknown
if type(u) == "table" then
    -- u narrowed to table here
end

Nil Safety

Types are non-nullable by default. Use ? for optional values:

local x: number = nil         -- ERROR: nil not assignable to number
local y: number? = nil        -- OK: number? means "number or nil"
local z: number? = 42         -- OK

Control Flow Narrowing

The type checker tracks control flow:

local function process(x: number?): number
    if x ~= nil then
        return x              -- x is number here
    end
    return 0
end

-- Early return pattern
local user, err = get_user(123)
if err then return nil, err end
-- user narrowed to non-nil here

-- Or default
local val = get_value() or 0  -- val: number

Union Types

local val: number | string = get_value()

if type(val) == "number" then
    print(val + 1)            -- val: number
else
    print(val:upper())        -- val: string
end

Literal Types

type Status = "pending" | "active" | "done"

local s: Status = "pending"   -- OK
local s: Status = "invalid"   -- ERROR

Function Types

local function add(a: number, b: number): number
    return a + b
end

-- Multiple returns
local function div_mod(a: number, b: number): (number, number)
    return math.floor(a / b), a % b
end

-- Error returns (Lua idiom)
local function fetch(url: string): (string?, error?)
    -- returns (data, nil) or (nil, error)
end

-- First-class function types
local double: (number) -> number = function(x: number): number
    return x * 2
end

Variadic Functions

local function sum(...: number): number
    local total: number = 0
    for _, v in ipairs({...}) do
        total = total + v
    end
    return total
end

Record Types

type User = {name: string, age: number}

local u: User = {name = "alice", age = 25}

Optional Fields

type Config = {
    host: string,
    port: number,
    timeout?: number,
    debug?: boolean
}

local cfg: Config = {host = "localhost", port = 8080}  -- OK

Generics

local function identity<T>(x: T): T
    return x
end

local n: number = identity(42)
local s: string = identity("hello")

Constrained Generics

type HasName = {name: string}

local function greet<T: HasName>(obj: T): string
    return "Hello, " .. obj.name
end

greet({name = "Alice"})       -- OK
greet({age = 30})             -- ERROR: missing 'name'

Intersection Types

Combine multiple types:

type Named = {name: string}
type Aged = {age: number}
type Person = Named & Aged

local p: Person = {name = "Alice", age = 30}

Tagged Unions

type Result<T, E> =
    | {ok: true, value: T}
    | {ok: false, error: E}

type LoadState =
    | {status: "loading"}
    | {status: "loaded", data: User}
    | {status: "error", message: string}

local function render(state: LoadState): string
    if state.status == "loading" then
        return "Loading..."
    elseif state.status == "loaded" then
        return "Hello, " .. state.data.name
    elseif state.status == "error" then
        return "Error: " .. state.message
    end
end

The never Type

never is the bottom type - no values exist:

function fail(msg: string): never
    error(msg)
end

Error Handling Pattern

The checker understands the Lua error idiom:

local value, err = call()
if err then
    -- value is nil here
    return nil, err
end
-- value is non-nil here, err is nil
print(value)

Non-Nil Assertion

Use ! to assert an expression is non-nil:

local user: User? = get_user()
local name = user!.name              -- assert user is non-nil

If the value is nil at runtime, an error is raised. Use when you know a value cannot be nil but the type checker cannot prove it.

Type Casts

Safe Cast (Validation)

Call a type as a function to validate and cast:

local data: any = get_json()
local user = User(data)              -- validates and returns User
local name = user.name               -- safe field access

Works with primitives and custom types:

local x: any = get_value()
local s = string(x)                  -- cast to string
local n = integer(x)                 -- cast to integer
local b = boolean(x)                 -- cast to boolean

type Point = {x: number, y: number}
local p = Point(data)                -- validates record structure

Type:is() Method

Validate without throwing, returns (value, nil) or (nil, error):

type Point = {x: number, y: number}
local data: any = get_input()

local p, err = Point:is(data)
if p then
    local sum = p.x + p.y            -- p is valid Point
else
    return nil, err                  -- validation failed
end

The result narrows in conditionals:

if Point:is(data) then
    local p: Point = data            -- data narrowed to Point
end

Unsafe Cast

Use :: or as for unchecked casts:

local data: any = get_data()
local user = data :: User            -- no runtime check
local user = data as User            -- same as ::

Use sparingly. Unsafe casts bypass validation and can cause runtime errors if the value doesn't match the type.

Type Reflection

Types are first-class values with introspection methods.

Kind and Name

print(Number:kind())                 -- "number"
print(Point:kind())                  -- "record"
print(Point:name())                  -- "Point"

Record Fields

Iterate over record fields:

type User = {name: string, age: number}

for name, typ in User:fields() do
    print(name, typ:kind())
end
-- name    string
-- age     number

Access individual field types:

local nameType = User.name           -- type of 'name' field
print(nameType:kind())               -- "string"

Collection Types

local arr: {number} = {1, 2, 3}
local arrType = typeof(arr)
print(arrType:elem():kind())         -- "number"

local map: {[string]: number} = {}
local mapType = typeof(map)
print(mapType:key():kind())          -- "string"
print(mapType:val():kind())          -- "number"

Optional Types

local opt: number? = nil
local optType = typeof(opt)
print(optType:kind())                -- "optional"
print(optType:inner():kind())        -- "number"

Union Types

type Status = "pending" | "active" | "done"

for variant in Status:variants() do
    print(variant)
end

Function Types

local fn: (number, string) -> boolean

local fnType = typeof(fn)
for param in fnType:params() do
    print(param:kind())
end
print(fnType:ret():kind())           -- "boolean"

Type Comparison

print(Number == Number)              -- true
print(Integer <= Number)             -- true (subtype)
print(Integer < Number)              -- true (strict subtype)

Types as Table Keys

local handlers = {}
handlers[Number] = function() return "number handler" end
handlers[String] = function() return "string handler" end

local h = handlers[typeof(value)]
if h then h() end

Type Annotations

Add types to function signatures:

-- Parameter and return types
local function process(input: string): number
    return #input
end

-- Local variable types
local count: number = 0

-- Type aliases
type StringArray = {string}
type StringMap = {[string]: number}

Variance Rules

Position Variance Description
Readonly field Covariant Can use subtype
Mutable field Invariant Must match exactly
Function parameter Contravariant Can use supertype
Function return Covariant Can use subtype

Subtyping

  • integer is a subtype of number
  • never is a subtype of all types
  • All types are subtypes of any
  • Union subtyping: A is subtype of A | B

Gradual Adoption

Add types incrementally - untyped code continues to work:

-- Existing code works unchanged
function old_function(x)
    return x + 1
end

-- New code gets types
function new_function(x: number): number
    return x + 1
end

Start by adding types to:

  1. Function signatures at API boundaries
  2. HTTP handlers and queue consumers
  3. Critical business logic

Type Checking

Run the type checker:

wippy lint

Reports type errors without executing code.