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
integeris a subtype ofnumberneveris a subtype of all types- All types are subtypes of
any - Union subtyping:
Ais subtype ofA | 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:
- Function signatures at API boundaries
- HTTP handlers and queue consumers
- Critical business logic
Type Checking
Run the type checker:
wippy lint
Reports type errors without executing code.