Language Reference#
Kvist is a small Lisp-shaped source language that lowers to ordinary Odin.
It keeps Odin's execution model visible: values are concrete, mutation is
explicit, allocations are explicit, and generated .odin should stay readable.
This document is the primary reference for Kvist syntax, semantics, and the main forms the compiler understands directly. Kvist also exposes large parts of ordinary Odin through its syntax and interop model, but this document is not a full Odin reference. When Kvist reuses an Odin concept directly, the goal here is to explain the Kvist spelling, semantics, and ownership rules rather than to restate every Odin API.
Scope And Model#
Kvist is easier to read if you know a few Odin-shaped ideas up front:
- a value is copied when passed around unless you explicitly use a pointer
- a pointer is a reference to some existing value; use it for shared identity or in-place mutation
- a fixed array like
[3]intstores threeintvalues inline - a slice like
[]intis a non-owning view of contiguous elements - a dynamic array like
[dynamic]intowns growable storage and must be deleted when you are done with it - a map like
map[string]intis an associative container; when you create one, it owns storage and must be deleted - Odin procedures can return multiple values directly, and Kvist keeps that model
- Odin uses explicit allocators for heap-backed storage; Kvist keeps that model too
Kvist does not add a garbage collector, hidden object model, or lazy sequence runtime on top of those rules.
Use this document as a reference, not a tutorial.
Surface Index#
The most common forms are:
; file and package structure
package import foreign-import @export @private @exports odin
; declarations
def def- defvar defvar- defstruct defstruct- defenum defenum-
defunion defunion- defn defn- defmacro defmacro-
deftransform deftransform- defiter defiter-
; local structure
let do block fn comment
; control flow
if when cond case match while for return discard break continue defer
when-let if-let when-ok if-ok
; mutation and places
set! mut! update! delete! inc! dec! toggle! negate!
assoc update dissoc dissoc-in get slice
; ownership and allocators
make alloc delete zero with-allocator with-temp-allocator
; pointers and types
addr deref ptr type transmute type-assert
; polymorphism
overload
; core helpers
count empty? contains? nil? or-else println str
; threading, setup, and inspection
-> ->> cond-> as-> doto tap> doc
; bit operations
bit.and bit.or bit.xor bit.not bit.shift-left bit.shift-right
bit.and-not bit.test bit.set bit.clear bit.flip
Source Files, Packages, And Names#
Reader Syntax#
Kvist uses Clojure-style reader syntax:
(head args...)for calls and language forms[...]for bindings, parameters, positional aggregates, and collection literals{...}for labeled aggregates and map literals#{...}for set literals
Whitespace and commas are interchangeable separators. Strings may span lines. Numbers and string escapes use Odin spelling.
Reader prefixes are:
'form ; quote
`form ; quasiquote
~form ; unquote
~@forms ; splice
#_form ; discard the next form
#"..." ; regex pattern
Quote produces immutable Data in runtime code and source forms in macro
code. See data.md and macros.md.
File Model#
Kvist source files use the .kvist extension. A folder is a package. Files in
the same folder use the same package name and form one compilation unit.
For example:
app/
main.kvist
users/
model.kvist
format.kvist
Both files under users/ start with:
(package users)
Declarations in model.kvist and format.kvist can see and call one another
without imports. The filename does not create another namespace.
app/main.kvist imports the folder:
(package main)
(import users "users")
(defn main []
(println (users.display-name (users.User {name: "Ada"}))))
package is optional only for the root source passed to kvist; omitted root
packages default to main. Files in imported Kvist source packages must declare
exactly one package, and all files in that package directory must use the same
package name.
This differs from Clojure, where a source file normally declares its own
namespace and other files require that namespace. In Kvist, the folder is the
package, every file in it contributes declarations to that package, and only
code outside the folder imports it.
import declarations require a preceding package declaration. Imports belong
before ordinary declarations.
Ordinary .odin files remain ordinary Odin. A Kvist package directory may
contain both .kvist and .odin files:
- imported package directories are treated as Kvist source packages when they
contain
.kvistfiles - Odin source files in mixed-language packages are available through the same package import alias as their Kvist declarations
- root
run,build,check, andtestcommands compile the generated Kvist output and the package's Odin source files together by generating a temporary Odin file in the package directory and building that directory
Use foreign-import for Odin foreign imports:
(foreign-import sqlite "system:sqlite3")
Raw Odin inside a .kvist file is explicit and should be reserved for cases
without a canonical Kvist form:
(odin "some_odin_only_construct()")
Use odin-infix and odin-prefix when a macro needs to splice Kvist
subexpressions into a raw Odin operator expression:
(odin-infix "&" flags mask)
(odin-prefix "~" mask)
Declarations#
The main declaration forms are:
(def Max-Retries 3) ; immutable value or type alias
(defvar request-count 0) ; mutable value
(defstruct User {
name: string
active?: bool
})
(defenum State [Pending Ready Failed])
(defunion Result {
user: User
error: string
})
(defn greet [user: User] -> string
(str "Hello, " user.name))
Add - to the form name to make a top-level declaration private to its
package: def-, defvar-, defstruct-, defenum-, defunion-, and defn-.
Macros, transforms, and iterators use defmacro, deftransform, and defiter.
The declarations and
compile-time forms sections cover the full syntax.
Names And Symbols#
Names are Clojure-style symbols. Ordinary declaration and local names use
letters, digits, _, -, ?, and !, and cannot start with a digit. Examples
are request-count, active?, push!, App-State, and value_2. Whitespace,
commas, and collection delimiters end a symbol.
The punctuation has conventions:
-separates words?marks predicates!marks mutation- a leading
.is a field selector, such as.name .inside a name accesses a package or field, such asarr.maporuser.name- operator symbols such as
+,<=, andbit.andare used in call position
Field access and package access use dot syntax:
user.name
fmt.println
arr.map
Field selectors such as .name and .age are not values by themselves. They
are special shorthand in supported places such as get, assoc, update,
arr.map, arr.filter, and similar helpers.
Keywords are ordinary values of type keyword. They are useful for lightweight
symbolic data in otherwise Odin-shaped code:
(defstruct Config {
mode: keyword
})
(Config {mode: :env/dev})
Kvist still uses specific keyword literals positionally in some forms. For
example, :defer, :errdefer, :using, :or-return, :yield, :next,
:dispose, and cond's :else act as markers in those syntactic slots.
Generated Odin uses a predictable mapping:
-becomes_?becomes_p!becomes_bang- case and existing underscores are preserved
For example, route-add, active?, and push! become route_add,
active_p, and push_bang. This usually matters only when reading generated
code or calling Kvist declarations from Odin.
Imports And Exports#
Imports are uniform:
(import fmt "core:fmt")
(import arr "kvist:arr")
(import "kvist:arr" :as arr)
(import "kvist:arr" :refer [map filter reduce])
(import support "support")
Imports either name an alias explicitly or opt into selected bare helpers with
:refer. (import arr "kvist:arr") and (import "kvist:arr" :as arr) both
expose qualified names such as arr.map.
(import "kvist:arr" :refer [map filter reduce]) exposes those helpers bare
and also keeps the package's default qualified alias available.
Plain path-only imports are not valid Kvist source. Use an alias for ordinary
package access, or use :refer when a file intentionally wants selected public
helpers bare.
Relative imports are resolved by inspecting the target:
- a target with
.kvistfiles is a Kvist source package - an Odin-only target remains an ordinary Odin import
kvist:*imports load shipped Kvist packagescore:*,base:*,vendor:*, and other Odin package paths remain Odin
Relative paths are resolved from the file containing the import.
There is no :odin import marker.
Use @export to attach Odin @(export) to the next top-level declaration.
Use @private to attach Odin @(private) to the next top-level declaration.
Use @exports [Name ...] to expose names that are part of the generated Odin
surface but are not declared by ordinary Kvist declarations, such as names
introduced through raw Odin.
@export
(defn callback :abi "c" [ctx: rawptr] -> void
...)
@private
(defn hidden [] -> int #force_inline
42)
@exports [Raw_Handle]
Ownership And Cleanup#
Kvist uses explicit ownership. Dynamic arrays, maps, sets, built strings, and many package helpers return owned storage. The owner must return it, transfer it, or clean it up.
Odin's explicit form works:
(let [xs (arr.range 0 10)]
(defer (delete xs))
(println xs))
For a local binding, prefer the equivalent :defer marker:
(let [xs (arr.range 0 10) :defer]
(println xs))
Use :defer-with when a resource has its own cleanup function:
(let [file (open-file path) :defer-with close-file]
(read-header file))
Keep the marker on the same line as its binding. Cleanup runs when the
surrounding scope exits. Borrowed slices and string views are not deleted; the
storage they point into must remain alive. Compiler-managed Data values need
no cleanup marker.
Use (addr value) or the shorter &value when a mutating function needs a
pointer:
(update-state! &state)
See Ownership, Allocation, And Context for
transfers, allocators, :errdefer, and compiler warnings.
Types, Values, And Data Shapes#
Kvist reuses Odin's data shapes directly.
Scalars#
Primitive scalar types include bool, integer types such as int, i32,
u64, floating-point types such as f32 and f64, and string-like types such
as string, cstring, rune, and byte.
Strings are plain Odin strings. They are values, not objects with methods.
Boolean literals are true and false. nil is the nil value used for
pointers and other Odin values that accept nil.
String literals may span lines:
(def Query: string "[:find ?name
:where [?e :user/name ?name]]")
Regex pattern literals use Clojure-shaped #"..."
syntax and lower to Odin
strings while preserving regex backslashes:
#"\d+"
Use kvist:regex for compiled regex ownership and matching helpers:
(import re "kvist:regex")
(re.matches? #"\d+" "abc123")
Compiled regexes and capture arrays are ordinary owned Odin values; destroy
them with regex.destroy! and regex.destroy-capture! when using the explicit
compile or match APIs. For scoped locals, use :defer-with:
(let [[compiled err] (re.compile #"^a+$")]
(if (= err nil)
(let [owned compiled :defer-with re.destroy!]
(re.matches-compiled? owned "aaa"))
false))
Keywords#
keyword is a symbolic scalar for tags, modes, states, and other
closed-world labels:
:dev
:queued
:not-found
:http/status
At lowering time, Kvist emits:
keyword :: distinct string
That keeps the runtime model Odin-shaped: a keyword is just a distinct string value, not an interned dynamic object. Equality, map keys, set membership, and struct fields therefore work through ordinary Odin value semantics.
Keywords may include / for Clojure-style grouping, such as :job/queued or
:http/status. The namespace part is ordinary data, not package or import
resolution.
Use keyword when the value is symbolic and stable:
(defstruct Result {
status: keyword
})
(Result {status: :ok})
Prefer string when the value is user-facing text, open-ended input, or needs
full text processing.
Fixed Arrays#
A fixed array stores a known number of elements inline:
([3]int [1 2 3])
This is useful when the size is part of the type.
Slices#
A slice is a pointer plus a length: a cheap, non-owning view over contiguous
elements. Its type is []T, where T is the element type:
([]int [1 2 3])
Indexing reads one element. Slicing creates another view:
xs[i] ; one element
xs[:] ; the whole sequence
xs[start:] ; start through the end
xs[:end] ; beginning through end, excluding end
xs[start:end] ; start through end, excluding end
The call-shaped equivalents are (get xs i), (slice xs),
(slice xs start), and (slice xs start end).
A slice may modify its backing storage:
(defn clear! [values: []int]
(for [value index values]
(set! values[index] 0)))
It does not own that storage. Do not delete a slice, and do not keep or return one after its backing value has been deleted:
(let [xs (arr.dynamic int [10 20 30 40]) :defer
middle xs[1:3]]
(set! middle[0] 99)
(println xs)) ; [10, 99, 30, 40]
Here xs owns the allocation and middle borrows it. Fixed arrays, dynamic
arrays, and strings can all provide slice-like views; the owner must outlive
every view.
Dynamic Arrays#
A dynamic array owns growable storage:
([dynamic]int [1 2 3])
Dynamic arrays must be deleted when locally owned:
(let [xs ([dynamic]int [1 2 3]) :defer]
(count xs))
Maps#
Maps are associative containers:
(map[string]int {"ok" 200 "missing" 404})
(map[keyword]int {:ok 200 :missing 404})
Like dynamic arrays, maps own storage when created and must be deleted when locally owned.
Sets#
set[T] uses Odin's set representation directly and lowers to
map[T]struct{}:
set[string]
Like maps and dynamic arrays, sets own storage when created and must be deleted when locally owned.
Structs#
Structs group named fields into one concrete value:
(defstruct User {
name: string
age: int
})
(User {name: "Ada" age: 36})
Struct values are copied by value unless passed through a pointer. Omitted fields in a struct literal use Odin zero values.
Field metadata accepts ordinary type spelling, including compact Odin-like type tokens:
(defstruct Batch {
ids: []int
tags: set[string]
weights: [4]f32
})
Use :using after a field type when you want Odin to promote the embedded
field's members onto the containing struct. This is useful for composition:
the containing value still stores a normal named field, but callers can access
the embedded field's members directly through the outer value.
(defstruct Logger {
level: int
})
(defstruct App {
logger: Logger :using
config: Config
})
(defn app-level [app: App] -> int
app.level) ; promoted from app.logger.level by Odin
This lowers to:
App :: struct {
using logger: Logger,
config: Config,
}
Use ordinary fields when you want explicit access such as app.logger.level.
Use :using when Odin's field/procedure promotion is the intended API.
The parser also accepts [slice T] as a vector shorthand in defstruct field
metadata. It lowers to []T. Use ordinary type spelling such as [dynamic]T,
[N]T, and (set T) for dynamic arrays, fixed arrays, and sets.
Struct fields use ordinary Odin-shaped types. A plain native struct does not gain hidden lifecycle behavior from its field types or from specially named procedures.
When a struct is used as a target of data.decode or data.validate, Kvist has
structural evidence that decoded strings, dynamic arrays, nested decoded
structs, and Data fields are acquired values. It then generates recursive
copy, replacement, and cleanup support for that decoded shape. This is inferred
from the boundary operation rather than declared on the type.
Opaque handles and structs assembled through arbitrary native code remain
explicit, as in Odin. Give them ordinary cleanup functions and use defer or
:defer-with at the owning scope.
Use :default after a field type to replace its zero-value construction
default:
(defstruct Settings {
port: i64 :default 8080
label: string :default "local"
})
(Settings {})
Defaults are evaluated when an omitted field is constructed. At a decoded boundary, they follow the inferred structural lifetime of the resulting shape. Struct signatures and editor metadata retain the annotation, and obvious literal type mismatches are compiler errors.
Enums#
Enums define a named integer-like set of values:
(defenum Method [
Get
Head
Post
])
(defenum Http-Status {
OK: 200
Not-Found: 404
})
Use .Name to refer to an enum member:
.Get
.Not-Found
Unions#
Unions define tagged values that can contain one of several payload shapes:
(defunion Value {
i: int
s: string
})
(Value {i: 42})
(Value {s: "kvist"})
Use case to inspect the active payload.
Pointer Types#
A pointer refers to some existing value instead of copying it:
^User
(ptr User)
Use pointers for:
- in-place mutation
- optional or shared identity
- passing large values around without copying them
Procedure Types#
Procedures are values too. Function types use fn:
(fn [x: int] -> int)
That type means "a procedure taking one int and returning one int."
Type Constructors And Polymorphic Types#
Most type shapes can be written with compact Odin-like tokens or list-shaped constructors. These are equivalent where both are accepted:
[]T (slice T)
[dynamic]T (dynamic T)
[N]T (array N T)
map[K]V (map K V)
set[T] (set T)
^T (ptr T)
Package helpers often use Odin-style polymorphic parameters. A type prefixed
with $ introduces an inferred type parameter; the unprefixed name refers to
that inferred type later in the signature:
(defn contains? [m: map[$K]$V, key: K] -> bool
(contains? m key))
(defn write-json [path: string, value: $T] -> os.Error
...)
Use $T: typeid when the caller passes a type explicitly:
(defn read-as [$T: typeid, path: string] -> [value: T, err: os.Error]
...)
Declaration Details#
Top-level declarations are public by default. Add - to make a declaration
package-private:
(def answer 42)
(def- internal-scale 3)
(defvar counter 0)
(defvar- private-counter 0)
Typed declarations use name: Type:
(def default-port: int 8080)
(defvar current-state: State (State {}))
An uninitialized typed defvar starts with the type's zero value:
(defvar current-state: State)
def is immutable but is not limited to compile-time constants. Calls to
single-result Kvist functions are initialized once before main, with their
return type inferred:
(import edn "kvist:edn")
(def config (edn.read-file "config.edn"))
Runtime bindings initialize in declaration order. Managed Data bindings are
released in reverse order at package shutdown. Reads remain direct typed
accesses; there is no Var indirection. Use an explicit type for runtime forms
whose result cannot be inferred from a single-result Kvist call.
Untyped def also declares Odin type aliases when the right-hand side is a
type expression:
(def Handle (distinct rawptr))
(def Order-Groups map[int][dynamic]Order)
These lower to ordinary Odin aliases:
Handle :: distinct rawptr
Order_Groups :: map[int][dynamic]Order
Quoted values are first-class immutable Data. This is the dynamic data
island for heterogeneous Lisp/EDN-shaped values; unquoted vectors, maps, and
sets remain native homogeneous collections.
(import data "kvist:data")
(def config
'{:port 8080
:features #{:query :pull}})
(def query
'[:find ?name
:where [?e :user/name ?name]])
(let [port (data.int (get config :port))
features (get config :features)]
(println port
(contains? features :query)
(count query)))
An unquoted collection literal is also Data when its surrounding type context
expects Data. Its scalar literals become Data values, while symbols and calls
are evaluated and converted from Data or native scalar values:
(def default-contact: Data
{:contact/name "Ada"
:contact/active? true})
(defn contact [id: i64, name: string] -> Data
{:db/id id
:contact/name name})
(data.conj transactions
[:db/add [:contact/id id] :contact/email email])
Return types, typed bindings, function parameters, and uniquely matching overload parameters provide this context. Without Data context, unquoted vectors, maps, and sets remain native homogeneous collections. A type annotation selects the Data representation while expressions inside the collection are still evaluated; quote instead preserves the form itself without evaluation.
Data represents nil, booleans, integers, floats, strings, symbols, keywords,
lists, vectors, maps, sets, and tagged values. Quoted literals use static
backing storage, are cheap to copy and pass, and require no cleanup. Use get,
contains?, and count for structural access. The data.int, data.float,
data.bool, data.string, data.symbol, and data.keyword accessors cross
from dynamic data into native typed values; data.vector? and the corresponding
kind predicates inspect shapes. The kvist:data package also provides
data.nth, data.get-in, data.keys, and data.vals.
Keywords and Data maps can be called as map lookups:
(:owner message)
(:owner message :unknown)
(message :owner)
(message :owner :unknown)
These forms borrow the stored value. The optional fallback is used only when the key is absent; a present Data nil is preserved. This is Data-specific lookup syntax, not a general callable-value protocol.
Tagged Data values are created with data.tagged or read from tagged EDN.
The compiler owns the representation-sensitive primitive operations, quote
lowering, and managed lifetime protocol. Higher-level construction, persistent
updates, traversal, and EDN operations live in the shipped kvist:data and
kvist:edn source packages. Runtime-owned values are deterministically managed;
immortal quoted literals retain the zero-cleanup path.
Local declarations use the same names and are scoped to the current block.
Local defstruct, defenum, and defunion declare block-scoped Odin types;
the declarations themselves are compile-time declarations, not runtime
allocations.
(let []
(def limit 10)
(defvar total 0)
...)
Use let when you want to introduce initialized local names as part of one
expression. Use defvar when the local should behave like an ordinary mutable
declaration that is updated across several later statements:
(defn sum-until-zero [xs: []int] -> int
(defvar total 0)
(for [x xs]
(if (= x 0)
(break))
(set! total (+ total x)))
total)
This is often clearer than forcing a dummy let binding just to create a place
that will be mutated later.
Use _ when a binding exists only to evaluate and discard an expression:
(let [_ (record-metric)
[value _] (lookup key)]
value)
These forms are also valid directly inside a function body:
(defn classify-code [code: int] -> int
(def limit: int 99)
(defenum Status [OK Large])
(defstruct Payload {code: int status: Status})
(defunion Value {payload: Payload raw: int})
(let [payload (Payload {code: code status: .OK})
value (Value {payload: payload})]
(case value
(Payload item) (if (> item.code limit) 1 0)
(int raw) raw
-1)))
This does not mean Kvist creates a new enum, struct, or union every time the function runs. These are still compile-time declarations. They are scoped to the function body in source, but they lower as local type and binding declarations in the generated Odin rather than as runtime "define a type now" operations.
Use function-scoped declarations when a helper type or constant only makes sense inside one function and would add noise at top level.
Structs, enums, unions, transforms, sources, and macros use the same public / package-private split at top level:
(defstruct Point {
x: f32
y: f32
})
(defenum Status {
Ready: 1
Done: 2
})
(defunion Payload {
text: string
code: int
})
(deftransform- internal-transform
(comp (map normalize)))
(defiter- internal-source [] -> Source_State :yield int
:next next-source-item
(open-source))
(defmacro- internal-macro [x]
...)
Package-private top-level names are available inside their own file/package but are not exported through Kvist source-package imports.
Functions And Calling#
Functions are declared with defn:
(defn distance [a: Point, b: Point] -> f32
...)
(defn- helper [x: int] -> int
(+ x 1))
The final form of a single-result function is returned implicitly. Use
return for early exits and direct multiple returns.
Use :abi when a function must use a specific foreign ABI:
(defn callback :abi "c" [ctx: rawptr] -> void
...)
Directive wrappers such as #force_inline can appear on function declarations:
(defn tiny-helper [x: int] -> int #force_inline
(+ x 1))
(defn query [] -> [value: int, ok: bool] #optional_ok
(return 42 true))
Caller intrinsics use Odin spelling:
(import rt "base:runtime")
(defn location
[loc: rt.Source_Code_Location = #caller_location]
-> rt.Source_Code_Location
loc)
(defn expression
[x: bool, text: string = (#caller_expression x)]
-> string
text)
Kvist infers lifetime boundaries from ordinary procedure bodies:
(defn allocate [] -> [dynamic]int
(make [dynamic]int))
(defn consume [values: [dynamic]int]
(delete values))
(defn view [value: Data] -> Data
value)
allocate is inferred to return new storage because every return path
allocates. consume is inferred to consume values because the body deletes
it. view is inferred to return a borrowed view because it aliases its
parameter. Callers move compiler-tracked owned locals into consuming calls and
disable the former scope cleanup; a later use is diagnosed.
Inference is conservative. A function that may return either an input array or
a new array is not treated as transferring ownership. Unknown or opaque native
resources keep explicit Odin semantics. Use ordinary cleanup functions with
defer, :defer, or :defer-with.
The forms (owned T), (borrowed T), #owned, #borrowed, and
managed: metadata are not part of Kvist. Procedure names such as
Type-destroy and Type-clone are ordinary names and do not install a
lifecycle protocol.
Use kvist lifetimes path/to/file.kvist to inspect the boundaries inferred by
the compiler and the evidence category used for each result and parameter.
The Kvist runtime has a reviewed foreign-binding table for operations whose
native bodies are opaque to the source analyzer. It records facts such as
“kvist_data_retain returns a new shared reference” and
“kvist_data_item_at returns a borrowed view.” This table describes Kvist's
own runtime ABI; it is not a Vev/Ro special case and does not classify arbitrary
foreign procedures by their names. Unknown foreign resources remain explicit.
Other Odin-style proc directives stay available in the same position when you need them.
Polymorphic functions may add one Odin where constraint immediately after the
signature:
(defn same? [value: $T, expected: T] -> bool
(where (intrinsics.type-is-comparable T))
(= value expected))
Polymorphism, Formatting, And Overloads#
Kvist supports parametric polymorphism through $ type parameters. A type
prefixed with $ introduces an inferred type parameter, and the unprefixed name
refers to that same type later in the signature:
(defn debug-str [value: $T] -> string
(fmt.aprintf "%v" value))
The fmt package accepts different value types, so this kind of helper is
useful for debugging and tooling. The returned string is owned because
fmt.aprintf returns an allocator-backed string. Use fmt.tprintf for
temporary-allocator strings that are consumed immediately and not deleted
manually.
For ordinary construction, use core str:
(let [request (str "@get('" path "', {openWhenHidden: true})") :defer]
(println request))
str accepts Odin-printable values, does not interpret braces or percent
signs in its string arguments, and lowers to one allocator-backed
fmt.aprintf call. Its result is always an owned string, including (str), so
bind it with :defer, delete it explicitly, or return it.
Use $T: typeid when the caller passes a type explicitly:
(defn read-as [$T: typeid, path: string] -> [value: T, err: os.Error]
...)
(read-as (type Config) "config.json")
Use where when a generic helper should only accept types that satisfy a
compile-time predicate:
(import intrinsics "base:intrinsics")
(defn same? [value: $T, expected: T] -> bool
(where (intrinsics.type-is-comparable T))
(= value expected))
For ad hoc overloading, use def with an overload right-hand side:
(defn render-int [value: int] -> string
(fmt.aprintf "int:%d" value))
(defn render-user [user: User] -> string
(fmt.aprintf "user:%s" user.name))
(def render (overload render-int render-user))
(defn render-supported [value: $T] -> string
(render value))
The same form works for local def declarations inside a function body when
the overload set is only useful in that local scope.
This lowers to:
render :: proc{render_int, render_user}
Overload members are written with normal Kvist names. Resolution happens at each specialized call site. If no overload matches, the generated program reports the supported overloads.
Anonymous functions use fn:
(arr.map (fn [x: int] -> int (+ x 1)) xs)
Non-capturing fn values lower to ordinary Odin procedure values. Captured
fn literals lower to explicit context-passing calls when the compiler can
prove the callback does not escape.
Captured callbacks are not general closure values. They cannot be stored, returned, or passed to unknown escaping APIs. Captured locals become extra proc parameters in generated Odin, not heap closure objects.
Calls#
Ordinary calls are list-shaped:
(println "hello")
(+ 1 2 3)
(fmt.tprintf "user-%d" 42)
Kvist also supports named arguments for API-shaped functions. Named arguments are passed as a single brace literal at the end of the call:
(defn greet [name: string, punctuation: string = "!"] -> string
...)
(defn place [name: string, x: int, y: int, label: string = "ok"] -> string
...)
(greet "Ada")
(greet {name: "Linus" punctuation: "?"})
(place "enemy" {x: 10 y: 20})
Parameters with defaults must trail required parameters. Defaults can be omitted positionally from the tail or omitted by name. Mixed calls keep a positional prefix and name the remaining tail:
(place "enemy" {x: 10 y: 20 label: "boss"})
Named arguments use field: labels, reject duplicates, and reject names that do
not match the callee's parameters. A named argument cannot overlap a positional
argument already supplied.
Multiple Return Values#
Kvist keeps Odin's direct multi-return model:
(defn divmod [n: int, d: int] -> [q: int, r: int]
(return (/ n d) (% n d)))
(defn parse-count [text: string] -> [value: int, ok: bool]
(return (count text) true))
Multiple return values bind positionally:
(let [[q r] (divmod 17 5)]
(println q r))
(let [[value ok] (parse-count "42")]
(if ok value 0))
This is the ordinary pattern for "value plus success flag" and "value plus error" APIs.
The most common multi-return shapes are:
[value: T, ok: bool]for parsing, lookup, search, and "found?" style APIs[value: T, err: Some_Error_Type]for Odin APIs where the zero error value means success- small tuples such as
[q: int, r: int]where both values are part of the result
Kvist does not wrap these in result objects. You bind the values directly and branch explicitly.
For guard-oriented helpers such as when-let, if-let, when-ok, if-ok,
and :or-return, Kvist checks the last returned value only.
- if the last value is a
bool,truemeans success andfalsemeans failure - if the last value is an Odin error type, the zero error value means success and a non-zero error means failure
The earlier returned values are just ordinary bound results. They are not packed into a special tuple object and they are not inspected as conditions.
So these two forms are applying the same rule to different final return types:
(if-let [[value ok] (lookup key)]
value
fallback)
(if-ok [[data err] (os.read_entire_file path context.allocator)]
data
fallback)
In the first case the last value is ok: bool. In the second case the last
value is err: os.Error.
value, ok#
Use this shape when failure is an expected ordinary outcome:
(defn parsed-or-zero [text: string] -> int
(let [[value ok] (parse-count text)]
(if ok value 0)))
This is a good fit for:
- parse attempts
- map or table lookups
- search helpers
- optional conversions
value, err#
Use this shape when calling Odin-style APIs that return an explicit error value:
(defn read-byte-count [path: string] -> int
(if-ok [[data err] (os.read_entire_file path context.allocator)]
(do
(defer (delete data))
(count data))
0))
This is the ordinary Kvist style for error-returning APIs.
Literals, Constructors, And Conversion#
The general rule is: a type in call position constructs or converts a value of that type.
(Point {x: 1.0 y: 2.0})
(rl.Vector2 [10.0 20.0])
(f32 x)
([3]i32 [1 2 3])
(matrix[2 2]f32 [1 2 3 4])
(#simd[4]f32 [1 2 3 4])
(#soa[dynamic]Particle [(Particle {x: 0 y: 0 vx: 1 vy: 1})])
(bit_set[Permission; u8] [.Read .Execute])
(quaternion [0.0 0.0 0.0 1.0])
Vector literals are positional aggregate input. Brace literals are field-labeled aggregate input:
(rl.Vector2 [10.0 20.0])
(rl.Rectangle {x: 0 y: 0 width: 1 height: 1})
Inline collection literals are also available for the most common owned containers:
[1 2 3] ; [dynamic]int
{"one" 1 "two" 2} ; map[string]int
{:job/ready 1 :job/done 2} ; map[keyword]int
#{"math" "lisp"} ; set[string]
#{:env/dev :env/prod} ; set[keyword]
These are owned values, not persistent Clojure data structures. Delete them when a local binding owns them, return them to transfer ownership, or pass them to an API that takes ownership:
(let [xs [1 2 3] :defer
lookup {"one" 1 "two" 2} :defer
states {:job/ready 1 :job/done 2} :defer
tags #{"math" "lisp"} :defer
modes #{:env/dev :env/prod} :defer]
...)
Empty inline literals need type context:
(let [xs: [dynamic]int [] :defer
lookup: map[string]int {} :defer
states: map[keyword]int {} :defer
tags: set[string] #{} :defer
modes: set[keyword] #{} :defer]
...)
Use keyword when the value is a symbolic tag rather than user-facing text:
(defstruct Job {
state: keyword
label: string
})
(Job {state: :job/queued label: "thumbnail"})
Use (type Head Args...) when a type must appear as a value. This includes
explicit typeid arguments and instantiated polymorphic types:
(read-as (type Config) "config.json")
(linalg.identity (type matrix[2 2]f32))
(chan.create (type chan.Chan int) 1 context.allocator)
For Odin polymorphic struct literals, the type constructor can be used directly when the final argument is a vector or brace literal:
(queue.Queue int {})
(sc.State_Def Door-State {id: .Closed})
These lower to Odin generic type instantiation, for example
queue.Queue(int){} and sc.State_Def(Door_State){...}. Use (type ...)
when you need the type value itself, such as a parameter type, return type, or
typeid argument.
Use make for runtime or allocator-backed construction where Odin uses a
procedure-like allocation operation:
(make [dynamic]int)
(make [dynamic]int 0 128)
(make map[string]int)
For dynamic arrays, the common make shapes are:
(make [dynamic]T)for an empty dynamic array(make [dynamic]T n)for a dynamic array with lengthn(make [dynamic]T n cap)for a dynamic array with lengthnand capacitycap
Examples:
(let [xs (make [dynamic]int 0 128) :defer]
...)
(let [cells (make [dynamic]f32 grid-cells) :defer]
...)
Like Odin, these allocations use the current context.allocator by default.
If you want a different allocator, you can either pass it directly to make or
choose it lexically with with-allocator:
(let [scratch (make [dynamic]int 0 64 context.temp_allocator) :defer]
...)
(with-allocator [allocator context.temp_allocator]
(let [scratch (make [dynamic]int 0 64) :defer]
...))
Use alloc when you want an Odin new(T) pointer allocation:
(alloc Node)
(alloc Node context.temp_allocator)
Free an owned pointer from alloc with Odin's free when its allocator
requires individual cleanup.
Use zero to construct an explicit zero value for a type:
(zero [2]f32)
(zero bit_set[Permission; u8])
For many collection-building cases, the shipped helper packages provide more specific constructors with optional capacity arguments:
(arr.empty int)
(arr.empty int 128)
(map.empty string int)
(map.empty string int 256)
arr.empty creates an owned empty dynamic array. map.empty creates an owned
empty map. The optional numeric argument is a capacity hint. These helpers are
often the clearest choice when you want to build a collection incrementally with
append, arr.into!, map.assoc!, map.merge!, or direct indexed updates.
let can infer local binding types from arr.empty, map.empty, and map.of
calls.
There is no separate object-construction runtime. Struct construction is just type-call syntax over a brace literal.
Bindings, Blocks, And Local Flow#
let is an expression and a local scope:
(let [xs ([dynamic]int [1 2 3]) :defer
total (sum xs)]
total)
The final expression in the body is the value of the let.
if is an expression when both branches produce a value. when can also be
used as an expression when the result type is known:
(defn selected-index [selected?: bool] -> int
(when selected? 1))
The false branch of a when expression is the zero value for the expected type.
In the example above, false returns int{}. A single-form body can often provide
the type in a local binding; multi-form when expressions need an explicit
expected type from the surrounding context.
Use value-producing when when that zero value is the intended fallback. When
the false branch carries meaning, prefer if and spell both branches out.
Use do when a branch or callback needs several expressions:
(do
(println "loading")
(load-users))
block is the explicit block form when you want a block without new bindings.
That is useful when you want a nested scope for local declarations, defer, or
early control flow, but do not want a let binding list:
(defn first-large [xs: []int] -> [value: int, ok: bool]
(block
(defvar found 0)
(for [x xs]
(if (> x 100)
(do
(set! found x)
(return found true))))
(return)))
Here block is just introducing a scoped body. The mutable local comes from
defvar, not from a let binding list.
Native structs continue to use dot access or explicit locals. Flat vector bindings destructure statically known native fixed arrays, slices, and dynamic arrays by position:
(let [[first second _] values]
(+ first second))
The source is evaluated once, extra elements are ignored, and _ checks but
does not bind its position. A known fixed array that is too short is rejected
during Kvist compilation. Slices and dynamic arrays are checked at runtime with
a diagnostic stating how many elements the pattern requires. Native sequence
patterns currently accept only symbols and _; rest, :as, and nested
patterns are not supported. If the native result type is opaque to Kvist, bind
or annotate it before destructuring.
Vector binding also remains the syntax for native multiple return values. That lowering is separate and keeps Odin's exact-arity rules.
Data has Clojure-style map and sequential destructuring:
(let [{:keys [name roles]
:person/keys [email]
:strs [external-id]
:syms [status]
:or {name "Anonymous"}
:as original}
contact
[primary secondary & remaining :as all-roles]
roles]
...)
Explicit map entries use {local :source-key}. :keys, literal namespaced
:person/keys, :strs, :syms, :or, and :as follow their Clojure
meanings. ::alias resolution is not implied.
Sequential destructuring accepts Data lists and vectors. Missing positions,
nil, and a wrong collection kind behave as empty; extra positions are ignored.
An empty & rest binds Data nil, while a non-empty rest preserves list/vector
kind. Map defaults are evaluated only for absent keys, not explicit Data nil.
Defaults may refer to earlier destructured locals.
Every captured subvalue is an automatically managed Data local. Cleanup
markers are therefore rejected on Data patterns. A vector binding is selected
from the statically known source type: Data uses these Clojure-style rules;
native arrays and slices use positional indexing; other native expressions use
multiple-return binding.
Owned local bindings may use the :defer marker:
(let [xs (arr.empty int) :defer]
...)
This is shorthand for a matching defer (delete xs) at the end of the scope.
Use :defer-with when cleanup is a function other than delete:
(let [file (open-file path) :defer-with close-file]
...)
This lowers to:
file := open_file(path)
defer close_file(file)
When a cleanup-managed resource is passed to a call that returns an owned
Data value, that value may be returned from the scope: its retained reference
remains valid after the resource cleanup runs.
Cleanup markers are mutually exclusive. Use :defer for delete(value),
:defer-with for cleanup(value), or :errdefer for failure-only cleanup of
returned owned values.
For guarded multi-return bindings, :defer deletes the first bound value after
the guard succeeds:
(let [[data err] (read-text path) :or-return :defer]
...)
:defer-with works the same way for guarded multi-return bindings, but calls
the named cleanup function on the first bound value:
(let [[file err] (open-file path) :or-return :defer-with close-file]
...)
Use :errdefer when an owned value should be returned on success but cleaned
up if the function later returns an error:
(defn load-buffer [path: string] -> [data: [dynamic]byte, err: rawptr]
(let [[data err] (read-buffer path) :or-return :errdefer]
(if (invalid-buffer? data)
(do
(set! err (validation-error))
(return)))
(return data err)))
:errdefer lowers to an ordinary deferred conditional cleanup:
defer {
if err != nil {
delete(data)
}
}
It is only supported on [value err] bindings with :or-return in a
tail-position let, so the generated defer runs when the function exits. Use
:defer for unconditional scope cleanup.
Guarded Multi-Return Bindings#
Result bindings may use :or-return, :or-break, or :or-continue guards:
(defn parse-required [text: string] -> [value: int, ok: bool]
(let [[value ok] (parse-count text) :or-return]
(return value true)))
(while running
(let [[item ok] (next-item) :or-break]
(println item)))
(for [text texts]
(let [[value ok] (parse-count text) :or-continue]
(println value)))
:or-return requires named proc returns matching the bound names.
These guards are shorthand for a very common Odin-style pattern:
:or-returnmeans "if the success condition failed, return now":or-breakmeans "if it failed, stop this loop":or-continuemeans "if it failed, skip this iteration":errdefermay follow[value err] ... :or-returnto deletevalueonly when the function exits with a non-nilerr
They are designed for value, ok style bindings where the second bound value is
the success flag. More generally, they check the last bound value only. For
bool-terminated returns, false triggers the guard. For error-terminated
returns, a non-zero error triggers the guard.
The common helper macros for multi-return APIs are also available:
(when-let [[value ok] (lookup key)]
(println value))
(if-let [[value ok] (lookup key)]
value
fallback)
(when-ok [[data err] (read-file path)]
(println (count data)))
(if-ok [[data err] (read-file path)]
data
fallback)
Use when-let and if-let for value, ok style APIs. Use when-ok and
if-ok for value, err style APIs.
Each binding form can also contain several dependent pairs. Evaluation stops at
the first false ok or non-zero err:
(if-let [[user ok] (find-user id)
[profile ok] (find-profile user.profile-id)]
(render-profile profile)
fallback)
(if-ok [[data err] (read-file path)
[cfg err] (parse-config data)]
(validate-config cfg)
fallback)
These chains are local branch selection. They do not return errors
automatically and they do not accept let binding modifiers such as
:or-return, :defer, or :errdefer. Use let with those modifiers when a
step owns resources that must be cleaned up before later failures can branch.
when-let is the statement form for value, ok:
(let [total 0]
(when-let [[value ok] (parse-count "42")]
(set! total (+ total value)))
total)
Use it when failure should simply skip a side effect or local mutation.
if-let is the expression form for value, ok:
(if-let [[value ok] (parse-count text)]
value
0)
Use it when both the success and failure paths should produce a value. With
several binding pairs, the else branch is used when any ok is false.
when-ok is the statement form for value, err:
(when-ok [[data err] (os.read_entire_file path context.allocator)]
(defer (delete data))
(println (count data)))
Use it when the success branch performs work but the failure branch can simply do nothing.
if-ok is the expression form for value, err:
(if-ok [[data err] (os.read_entire_file path context.allocator)]
(do
(defer (delete data))
(count data))
0)
Use it when the failure path should produce a fallback value. With several
binding pairs, the failure branch is used when any err is non-zero.
The main distinction is:
when-let/if-let: the second value is aboolwhen-ok/if-ok: the second value is an error object or error pointer
There is no implicit condition coercion and no automatic exception model. Conditions are boolean, and success and failure stay explicit in the source.
Named Returns And Naked return#
When a procedure has named return values, those names are real local result
slots. You may assign to them and then use a naked return:
(defn parse-required [text: string] -> [value: int, ok: bool]
(if (= text "")
(return))
(set! value (count text))
(set! ok true)
(return))
A naked (return) returns the current contents of the named result slots. If
you have not assigned anything yet, those slots contain the zero values for
their types, just like Odin locals:
intreturns0boolreturnsfalse- pointers return
nil - slices, dynamic arrays, maps, strings, and other composite values return their zero values
- error return values return their zero "no error" value
That is why :or-return works naturally with named returns:
(defn read-required [path: string] -> [data: []byte, err: os.Error]
(let [[data err] (os.read_entire_file path context.allocator) :or-return]
(return data err)))
For :or-return, the binding names must match the named return slots exactly.
Kvist assigns the result into those slots before checking the guard. If
os.read_entire_file fails, err is already set, so the naked return produced
by :or-return returns the captured error.
Because :or-return assigns into named return slots, :errdefer observes the
same err slot at function exit. If later code sets err and returns, the
owned first value is deleted. If err is still nil on success, ownership stays
with the returned value. For that reason, :errdefer is rejected in non-tail
let forms where Odin would run the generated defer at block exit instead of
function exit.
Control Flow#
The core control forms are:
(if test then else)
(when test body...)
(while test body...)
(do body...)
(block body...)
(return value...)
(discard value...)
(break)
(continue)
(defer body...)
if and when are expression-oriented. when is the one-armed version of
if.
do evaluates a sequence of forms and returns the final value. block does
the same, but is used when you want an explicit nested scope for local
declarations, defer, or early control flow without a let binding vector.
while is the ordinary condition loop:
(while (< i n)
(println i)
(mut! i += 1))
return, break, and continue lower directly to the corresponding Odin
control flow.
discard intentionally ignores one or more expression results:
(discard x)
(discard x y)
This lowers to _ = ... assignments. It is useful when a value is intentionally
unused, but it does not override ownership rules: discarding a known owned
result still warns.
defer emits Odin defer. A single expression defers that expression;
multiple forms defer a block.
Inside for or while, defer still follows Odin scope rules: it runs when
the surrounding scope exits, not automatically after each iteration. Kvist warns
on direct loop-body defer forms. Wrap the iteration body in (block ...) when
you want a per-iteration scope, or clean up explicitly at the end of the loop
body.
cond#
Use cond when each branch has its own predicate:
(cond
(< n 0) "negative"
(= n 0) "zero"
:else "positive")
Vector clauses are also accepted when a branch needs several body forms:
(cond
[(< n 0) (println "negative") "negative"]
[:else "non-negative"])
case#
Use case when one subject is being classified. Arms are flat pattern and
expression pairs followed by a naked default expression. Use (do ...) when an
arm needs multiple forms.
Value cases expand to equality tests. Union and type payload cases lower to Odin type switches:
(case status
.Ready "ready"
.Done "done"
"unknown")
(case method
.Get "read"
.Head "read"
.Post "write"
"other")
(case state
:queued 0
:running 1
:done 2
-1)
(case event
(Connected conn) conn.id
(Disconnected _) 0
(Data data) (count data.payload)
-1)
A vector arm is one vector literal, not a way to group several case values. Its type comes from the subject context, and comparison follows Odin's equality rules. Repeat arms when several values share one result.
Use _ when a type payload case should match the variant without binding the
payload.
case may lower to Odin switch or #partial switch internally. Those are
generated Odin details, not Kvist source forms. Kvist source uses case for
subject dispatch and cond for predicate branches.
match#
Use match for structural Data dispatch. Arms are flat pattern/result pairs
and the final arm must be :else or _:
(match message
{:op :query :query query}
(run-query query)
(as whole {:op :transact :tx tx})
(validate-and-transact whole tx)
(kind :vector [head & tail])
(handle-sequence head tail)
:else
(unknown-message message))
Plain symbols capture Data and _ is the wildcard. Quote a symbol when it is a
literal pattern. Maps are open but every mentioned literal key is required.
Sequences are exact unless they contain &; both lists and vectors match an
unconstrained sequence pattern. Exact set patterns contain literals only.
(as name pattern) captures the complete value. (kind :vector pattern)
constrains the Data representation; the supported kind names are :nil,
:bool, :int, :float, :string, :symbol, :keyword, :list,
:vector, :map, :set, and :tagged. Captures remain Data, so native
conversion stays explicit through data.int, data.string, data.decode,
and related operations.
The subject is evaluated once and the first matching arm wins. Structural
tests borrow their input; captures are retained only after the complete arm
succeeds. Use case instead for native enums, unions, and ordinary values.
for#
Use for for side-effect iteration:
(for [x xs]
(println x))
(for [k v lookup]
(println k v))
(for [x i xs]
(println i x))
Unlike Clojure's for, this is not a lazy sequence builder. It is a loop.
Data patterns can be loop binders:
(for [[id title] rows]
...)
(for [{:keys [name email]} contacts]
...)
(for [index [id title] rows]
...)
Data list, vector, and set sources iterate in backing order; Data nil performs
zero iterations. Native arrays, slices, and dynamic arrays of Data are also
supported. Other runtime Data kinds report a source-mapped kind error.
Ordinary indexed iteration follows Odin's value/index order:
[value index source]. Indexed Data-pattern iteration uses
[index pattern source], as in the final example above.
Places, Mutation, And Value Updates#
Kvist exposes direct Odin-style places:
value.field
xs[i]
xs[:end]
xs[start:end]
xs[start:]
The call-shaped equivalents are available too:
(get value .field)
(get xs i)
(get lookup key default)
(slice xs)
(slice xs start end)
(slice xs start)
(slice xs 0 end)
Use place syntax when you want direct read or write access to storage.
Mutating Forms#
(set! place value) ; assignment
(mut! place += value) ; compound assignment
(update! place f args...) ; read, apply, write
(delete! target key) ; remove map/set key in place
Examples:
(set! robot.x nx)
(mut! particles.vx[i] += ax)
(update! point.y + 4)
(update! (get lookup "a") inc)
(delete! lookup "stale")
Unary mutation helpers are available for common place updates:
(inc! point.x)
(dec! xs[i])
(toggle! enabled)
(negate! velocity.x)
Non-Mutating Value Updates#
For native struct or immutable Data updates where you want a changed value
instead of mutating the original, use assoc and update:
(assoc user.name "Ada")
(assoc user.profile.name "Ada")
(update user.age inc)
(update user.profile.age + 1)
(assoc message :status :ready)
(update message :attempts increment-data)
Dispatch is resolved statically from the target type. Struct forms copy the root value once, update the selected field path, and return the copy. Data forms perform an immutable map or vector update and preserve structural sharing.
Remove map keys from Data with dissoc. It accepts one or more keys:
(dissoc message :temporary)
(dissoc message :temporary :debug)
Use dissoc-in with a Data list or vector path to remove a nested leaf:
(dissoc-in message '[:request :credentials])
Missing paths leave the original value unchanged. Empty parent maps are preserved rather than implicitly pruned.
Dynamic arrays, slices, maps, and sets are not path-updated this way; use
explicit copying or mutation for those. This restriction does not apply to
immutable Data collections.
Decoding Data Into Native Structs#
Use data.decode when a dynamic boundary should become a concrete native
struct:
(defstruct Settings {
port: i64
enabled: bool
metadata: Data
})
(let [[settings err ok]
(data.decode Settings message '[:settings])]
(if ok
(start settings)
(println err.path err.expected err.actual)))
The optional path becomes the root of any Decode-Error. Required nested
Kvist structs and Data, boolean, integer, floating-point, string, and enum
fields are supported. Enum keywords use lowercase source
spelling, so .Read-Only is represented by :read-only. A keyword outside the
enum sets err.enum-value?, err.expected-type, and err.actual-value.
Decoded string ownership is inferred from use of the struct as a decode target.
Nested validation completes before construction, so acquired leaves exist only
for a successful result. The decoded struct and error then receive
deterministic structural cleanup.
A field annotated :default value is optional at the Data boundary: a missing
map key evaluates the same default used by ordinary struct construction, while
a present key is still validated. Presence is checked independently from Data
nil, so an explicit nil does not select the default.
Fields declared [dynamic]T decode Data vectors into owning native dynamic
arrays when T is Data, bool, an integer scalar, or a
floating-point scalar, a Kvist enum, or a Kvist struct:
(defstruct Point {
x: i64
y: i64
})
(defstruct Batch {
ids: [dynamic]i64
points: [dynamic]Point
})
(data.decode Batch {:ids [10 20 30]
:points [{:x 1 :y 2}
{:x 3 :y 4}]})
Every element is validated before the native array is allocated. Errors append
the failing numeric index to the Data path, such as [:ids 1]. Nested struct
fields extend that path further, such as [:points 1 :x], and honor the same
defaults and managed-field rules as directly nested structs. Data elements
are retained; scalar and enum elements are stored unboxed. Invalid enum
keywords also populate expected-type and actual-value.
The same supported element types can be decoded directly when no wrapper struct is useful:
(let [[points err ok]
(data.decode
(dynamic Point)
[{:x 1 :y 2} {:x 3 :y 4}]
[:points])]
(if ok
(draw-points points)
(println err.path err.expected err.actual)))
points is an ordinary owned [dynamic]Point, not a persistent or boxed
collection. A result destructuring binding schedules deterministic cleanup,
including recursive destruction of managed struct elements. Direct decoding
also validates the complete Data vector before allocating native storage.
Native string arrays and borrowed slices are not supported decode targets.
Validating Data Without Decoding#
Use data.validate when Data should remain Data after checking a reusable
native shape:
(let [[err ok]
(data.validate Message message [:message])]
(if ok
(handle-data-message message)
(println err.path err.expected err.actual)))
The target may be any struct or (dynamic T) target accepted by
data.decode. Validation uses the same required fields, :default optional
fields, enum variants, nested structs, array elements, and path-aware
Decode-Error values. It returns [err ok] and does not construct the native
target, clone managed fields, or allocate native array storage. The original
immutable Data value is unchanged.
This is useful for validating once at a package or protocol boundary and then passing Data through code that relies on that boundary contract. Validation does not create a hidden runtime schema object or a distinct boxed/refined Data type; the native target type remains the single shape definition.
In a -> pipeline, use a .field selector step:
(-> user
(assoc .profile.name "Ada")
(update .profile.age + 1)
(assoc .name "Ada"))
Ownership, Allocation, And Context#
Kvist keeps Odin's explicit allocation model. It automatically manages Data
and aggregates whose nontrivial lifetime is structurally derived from Data.
Ordinary native strings, arrays, maps, and opaque resources still use explicit
Odin-style cleanup. Typed decode results are a narrow exception because the
decode boundary proves their complete allocation shape. This is deterministic
management, not tracing garbage collection.
If a value owns dynamic storage, delete it when the current scope is done with it. The common owned values are dynamic arrays, maps, and helper results that create them.
(let [xs (arr.range 0 8) :defer]
(for [x xs]
(println x)))
The practical ownership rules are:
- allocating native expressions remain explicit; use
defer,:defer, or:defer-with - a parameter is inferred as consuming when the procedure body explicitly deletes it or transfers it through a proven owning result
- parameters otherwise borrow
- if every return path proves a new value, ownership transfers to the caller
- borrowed views must not be deleted
- compiler-tracked
Data, structs whose lifetime derives from containedData, and decoded structural results receive deterministic generated cleanup - ambiguous or opaque imported resources use explicit
defer,:defer, or:defer-with - cleanup-like procedure names do not change a type's semantics
:deferis scope cleanup for ordinary owned values:defer-withis scope cleanup through a named cleanup function:errdeferis failure-only cleanup for[value err] :or-returnbindings- iterators use
:disposein theirdefiterdeclaration to name producer-state cleanup
Common owned values:
- dynamic arrays such as
(make [dynamic]int)or([dynamic]int [1 2 3]) - maps such as
(make map[string]int)or(map[string]int {"one" 1}) - collection helpers that build fresh dynamic arrays or maps, such as
arr.map,arr.filter,arr.partition,arr.range,map.keys,map.vals,arr.group-by, andarr.frequencies - file-read bytes from
io.readoros.read_entire_file
Common borrowed or plain non-owning values:
- fixed arrays such as
[4]int - plain structs, unions, enums, numbers, and booleans
- slices returned by view helpers such as
slice,arr.take,arr.drop, andarr.split-at - elements returned by helpers such as
arr.firstandarr.last
Strings and slices are views. Their backing storage may be borrowed or owned,
depending on how they were produced. For example, a string literal is static,
str returns an owned string, and trimming helpers commonly return borrowed
views.
Two ownership edges are worth calling out explicitly:
arr.partition,arr.partition-all, andarr.partition-byreturn an owned outer dynamic array whose inner chunks are borrowed slices. Delete the outer array only.tap>returns its input. It does not change ownership. If you tap an owned value, the result is still owned.
Allocator scopes are explicit:
(with-allocator [allocator expr]
body...)
(with-temp-allocator [allocator]
body...)
with-allocator temporarily overrides context.allocator and restores it with
defer.
with-temp-allocator starts a temp allocator scope, restores the previous
allocator state at scope exit, and rejects obvious owned values that would
escape that short-lived allocation scope.
Allocator scopes can also produce a value when the surrounding context provides the result type:
(let [count: int (with-temp-allocator [allocator]
(parse-count input))]
count)
The compiler also has coded ownership warnings for obvious mistakes. These warnings are advisory. They do not turn native values into an automatic ownership system, and they do not add hidden native cleanup to generated Odin.
Normal commands report definite findings. Add --ownership-audit to include
conservative findings from the flow analysis:
warning[KVO001]: owned result from arr.range is discarded; bind it, delete it, or return it
warning[KVO002, conservative]: owned local xs is never deleted or returned; add (defer (delete xs)) or return it
warning[KVO004]: owned local xs is overwritten before cleanup; delete it or return it before set!
warning[KVO003, conservative]: owned local xs is used after ownership transfer
warning[KVO005, conservative]: borrowed value escapes owner xs
Equivalent findings at the same source location are printed once. The compiler
API retains every warning with its stable code and confidence fields so
tools can choose their own policy. Explicit deferred destructors whose names
identify destroy, free, close, or release operations are recognized as cleanup.
The audit pass is intentionally conservative. It recognizes allocating return
paths, known owned-result helpers such as arr.range, arr.empty,
map.empty, and set.union; borrowed views that alias compatible inputs or
known view helpers such as slice, arr.slice, and arr.rest; and ownership
transfers such as delete, returning an owned local, and passing an owned local
into a consuming operation inferred from its body.
For example:
(defn bad-view [] -> []int
(let [xs (arr.range 0 10) :defer]
(arr.slice xs 0 3)))
This warns because the returned slice is a borrowed view into xs, and xs is
deleted when the let scope exits.
Valid local use of the same borrowed view does not warn:
(defn local-view-use [] -> int
(let [xs (arr.range 0 10) :defer]
(count (arr.slice xs 0 3))))
See examples/collections/ownership-warnings.kvist for a small warning
surface tour:
kvist check examples/collections/ownership-warnings.kvist
kvist check examples/collections/ownership-warnings.kvist --ownership-audit
The Implicit context#
Like Odin, Kvist code runs with an implicit context value in scope. This is
where allocator-sensitive code usually gets its default allocator from:
context.allocator
context.temp_allocator
Most code does not need to thread allocators through every call manually.
Instead, helper functions and package code often read context.allocator
directly when calling Odin APIs that allocate:
(os.read_entire_file path context.allocator)
(chan.create (type chan.Chan int) 1 context.allocator)
Use context.temp_allocator when you explicitly want temporary scratch
allocation rather than ordinary long-lived allocation.
Custom Allocators In Functions#
If a function should let the caller choose the allocator, take the allocator as an ordinary typed argument and pass it through to the allocating API:
(import mem "core:mem")
(defn read-with [path: string, allocator: mem.Allocator] -> [data: []byte, err: os.Error]
(os.read_entire_file path allocator))
Then the caller can choose:
(read-with path context.allocator)
(read-with path context.temp_allocator)
That is the basic pattern for allocator-aware helper functions: keep the normal
path simple by using context.allocator, and add an explicit allocator argument
when the caller genuinely needs control.
Lexically Overriding The Current Allocator#
When many operations in one block should share the same allocator, with-allocator
is usually cleaner than passing the allocator through every helper manually:
(with-allocator [allocator context.temp_allocator]
(let [scratch (make [dynamic]int 0 64) :defer]
...))
Pointers And Addressing#
Pointer types and pointer operations stay close to Odin. ^T and (ptr T) are
equivalent type spellings; use whichever is clearer in context.
(defn init [state: (ptr App-State)]
...)
(defn bump! [x: ^int]
(mut! x^ += 1))
(addr value)
&value
(deref ptr)
ptr^
Use addr or &value to take an address. Use ptr^ or (deref ptr) to read
or write through a pointer.
As a style rule, keep values as values unless shared identity or shared mutable access is actually required.
- pass small plain data by value
- pass large or shared mutable values by pointer
- use slices for shared contiguous read/write data
- use address-of and dereference only when identity or mutation through a reference is the real goal
Examples:
(defn counter-value [counter: ^Counter] -> int
counter^.value)
(defn counter-after-bump [] -> int
(let [counter (Counter {value: 41})]
(bump! (addr counter.value))
(counter-value (addr counter))))
Kvist does not add a borrow checker or automatic pointer-versus-value recommendations. Its ownership analysis can diagnose known escapes and use-after-transfer errors, but pointer use stays explicit.
Core Forms And Built-In Helpers#
Operators And Expression Helpers#
Operators lower to ordinary Odin expressions:
(+ a b)
(- total discount)
(* x y)
(% index width)
(min x y)
(max x y)
(and ok ready)
(or cached? fresh?)
(not done)
(bit.and flags mask)
(bit.shift-left major 22)
and, or, and not are boolean operators. They lower to Odin &&, ||,
and !; they do not return one of their input values.
This is intentionally different from Clojure:
; Kvist: boolean expression
(or cached? fresh?)
; Kvist: optional-ok fallback
(or-else (lookup-cache key) fallback)
The Clojure pattern of returning the first non-false/nil value does not work in Kvist:
; Clojure-style, not Kvist
(or cached-value fallback-value)
Use or-else when the expression returns [value, ok] and you want a fallback
value. Kvist does not treat arbitrary values as conditions; condition
expressions must be boolean.
+, *, /, and % take two or more operands. - also has a unary form.
min and max take two or more operands.
=, ==, <, <=, >, and >= support two or more operands and compare
adjacent values once:
(= a b c)
(< a b c d)
!= is intentionally binary.
Bit operations live in kvist:bit and lower to ordinary Odin integer
operators:
(import bit "kvist:bit")
(bit.and a b c) ; a & b & c
(bit.or a b c) ; a | b | c
(bit.xor a b) ; a ~ b
(bit.not mask) ; ~mask
(bit.shift-left x n) ; x << n
(bit.shift-right x n) ; x >> n
(bit.and-not flags mask) ; flags & ~mask
(bit.test flags index) ; bit at index is set
(bit.set flags index) ; set bit at index
(bit.clear flags index) ; clear bit at index
(bit.flip flags index) ; toggle bit at index
Directive expression wrappers attach Odin call directives to a call:
(inc 41 #force_inline)
(inc x #force_inline)
transmute is explicit and lowers to Odin's transmute(T)value form:
(transmute []byte text)
type-assert lowers to Odin's selector assertion value.(T) form:
(type-assert handler.next ^h.Handler)
Threading And Core Helpers#
Small core helpers are auto-exposed. Prefer the bare spelling:
(println value)
(count xs)
(get xs i)
(get lookup key default)
(slice xs start end)
(slice xs start)
(slice xs)
(empty? xs)
(contains? lookup key)
(or-else maybe fallback)
(nil? value)
(tap> value)
(tap> "label" value)
(doc 'println)
(-> value steps...)
(->> value steps...)
(cond-> value test step...)
(as-> value name expr...)
(doto value setup-calls...)
-> threads a value into the next form as the first argument. ->> threads it
as the last argument.
cond-> conditionally applies first-argument thread steps. It is useful when
one value is refined by several independent flags and each enabled branch should
continue from the value produced by earlier enabled branches:
(cond-> req
json? (assoc .content-type :json)
auth? (assoc .authenticated? true)
trace? (update .trace-id + 10))
The conditions and steps are evaluated in order. Each condition controls exactly
one step. A true condition applies the step as if it were written with ->; a
false condition leaves the current value unchanged. The result has the same
shape as the initial value because every step is a first-argument refinement of
that value. When several updates share one condition, use an ordinary if or a
helper function instead of repeating that condition in cond->.
as-> binds a name to the current value and rebinds that same name after each
step expression. Use it when the value does not naturally thread into only the
first or last argument position, or when the pipeline ends by projecting the
threaded value into a different type:
(as-> user x
(visit x)
(attach-bonus bonus x)
(+ x.age x.profile.visits))
The name is visible anywhere inside each step, including field access such as
x.profile.visits. Each step receives the result of the previous step through
that name, and the result of the whole form is the final step expression. Unlike
-> and cond->, as-> can change type across steps; the example starts with
a User and returns an int.
doto evaluates a value once, passes it as the first argument to each setup
call, and returns the original value. Use it for Odin-style mutating setup APIs
whose calls return void or status values rather than the configured object:
(let [configured (doto (addr cfg)
(set-port! 6969)
(enable-secure!))]
configured^.port)
count lowers to Odin len. count is the canonical Kvist spelling.
empty? checks whether the lowered Odin length is zero.
contains? is the cross-family membership predicate:
(contains? lookup key) ; map/set-style membership
(contains? xs value) ; array/slice/dynamic-array equality scan
(contains? text needle) ; string contains, when needle is string
Use (not (contains? collection value)) for absence. When membership depends
on a predicate instead of equality, use an array helper such as arr.some?:
(arr.some? (fn [x: int] -> bool (> x 10)) xs)
or-else expects a [value, ok] expression and returns either the value or the
fallback. nil? lowers to a direct nil comparison.
tap> prints a value for inspection and returns that same value unchanged. The
labeled form requires a string literal label:
(tap> user)
(tap> "user" user)
doc expects a quoted declaration name and prints the attached doc text for
that declaration:
(doc 'parse-port)
Shipped Packages#
Broader helpers are packages, not hidden language behavior. Import them explicitly.
Arrays#
kvist:arr is the main collection package:
(import arr "kvist:arr")
(defn add [x: int, y: int] -> int
(+ x y))
(let [numbers (arr.range 0 10) :defer
even (arr.filter (fn [x: int] -> bool (= (% x 2) 0)) numbers) :defer
squares (arr.map (fn [x: int] -> int (* x x)) even) :defer]
(println (arr.reduce add 0 squares)))
range, map, and filter build owned dynamic arrays in ordinary expression
position. reduce, find, some?, and every? scan without building an
output. Names ending in ! mutate existing storage:
(arr.push! numbers 10)
(arr.sort! numbers)
View helpers such as take, drop, and rest return borrowed slices.
Partition helpers return an owned outer array of borrowed slices. See
sequences.md for constructors, eager builders, scans,
producers, views, and ownership.
Maps, Sets, And Strings#
kvist:map, kvist:set, and kvist:str provide construction and common
operations:
(import map "kvist:map")
(import set "kvist:set")
(import str "kvist:str")
(let [scores (map.of string int {"Ada" 42}) :defer
roles (set.of keyword [:admin :author]) :defer
words (str.split "one,two,three" ",") :defer]
(println (map.get scores "Ada" 0)
(set.contains? roles :admin)
words))
Non-mutating map and set operations return new owned values. Their ! variants
mutate. String slicing and trimming borrow; joining, replacing, and changing
case return owned strings. See sequences.md.
Data And EDN#
kvist:data works with immutable heterogeneous Data. kvist:edn reads and
writes its EDN representation:
(import data "kvist:data")
(import edn "kvist:edn")
(let [config (edn.read "{:port 8080 :features [:query :pull]}")]
(println (data.int (:port config)))
(edn.prn config))
Data is compiler-managed. See data.md for construction, traversal,
updates, destructuring, validation, and typed boundaries.
Regular Expressions#
kvist:regex provides a simple one-shot API and explicit compiled values:
(import regex "kvist:regex")
(regex.matches? #"^[a-z]+$" "kvist")
(let [[pattern err] (regex.compile #"^[a-z]+$")]
(when (= err nil)
(let [owned pattern :defer-with regex.destroy!]
(println (regex.matches-compiled? owned "kvist")))))
Compiled regexes and captures use their package cleanup functions. See the package source for matching and capture APIs.
Parallel Work And Tests#
kvist:parallel runs tasks or bounded collection work:
(import p "kvist:parallel")
(let [squares (p.map square values) :defer]
(println squares))
kvist:test provides declarations and assertions:
(import t "kvist:test")
(t.deftest addition
(t.is (= (+ 2 3) 5)))
See parallel.md and testing.md.
Struct Of Arrays#
kvist:soa exposes Odin's struct-of-arrays layout. It stores each struct field
as a separate contiguous column:
(import soa "kvist:soa")
(let [particles (soa.make Particle 1024) :defer]
(soa.push! &particles (Particle {x: 1 y: 2 mass: 3}))
(soa.scale! particles .mass 0.5)
(println particles.mass[0]))
See Struct Of Arrays for the storage forms and column operations.
kvist:bit is covered with the core operators above. See
packages.md for the complete package index.
Compile-Time Forms#
Iterators And Transforms#
defiter defines a reusable stateful producer for for, into, and
transduce. The header names both types: the opener state returned by the
generated function, and the item type yielded by :next.
(defstruct File_Source {
items: []string
index: int
})
(defn next-file [src: ^File_Source] -> [path: string ok: bool]
(if (< src.index (count src.items))
(let [path src.items[src.index]]
(set! src.index (+ src.index 1))
(return path true))
(return "" false)))
(defn dispose-files [src: ^File_Source]
(set! src.index 0))
(defiter files [items: []string] -> File_Source :yield string
:next next-file
:dispose dispose-files
(File_Source {items: items index: 0}))
This emits an ordinary opener function:
(files items) ; returns File_Source
Consumers call :next with ^File_Source until ok is false. :dispose,
when present, must take ^File_Source and return no value; consumers defer it
after opening the iterator.
Iterators are consumed by for, into, and transduce:
(for [path (files items)]
(println path))
(into [dynamic]string
(comp
(filter odin-path?))
(files items))
(transduce
(comp
(filter odin-path?)
(map path-length))
+ 0
(files items))
deftransform defines reusable compile-time transform structure. A transform
can be collected with into or reduced with transduce; both lower to fused
Odin loops rather than intermediate arrays.
(deftransform paid-order-totals
(filter paid?)
(map order-total)
(filter positive?))
(into [dynamic]int paid-order-totals orders)
(transduce paid-order-totals + 0 orders)
(for [total orders :transform paid-order-totals]
(println total))
See transforms.md for supported steps, sources, outputs, and ownership.
Macros#
defmacro defines source macros over Kvist forms:
(defmacro name [arg ...]
...)
Macros expand before ordinary parse and lowering. Macro code should emit current Kvist syntax.
Use macros when the source shape matters more than runtime values.
See macros.md for the full macro authoring surface.
Documentation And Comments#
Use ; for line comments; repeated semicolons such as ;; are conventional
for standalone comments. Ignoring the next form is supported with #_:
#_(+ 1 2 3)
Immediately preceding comments without a blank line also attach as doc text. Supported declarations may also take inline docstrings:
;; Parse a port number from a string.
(defn parse-port
"Parse a port number from a string."
[s: string] -> int
...)
(def port
"Default port number."
8080)
Anything wrapped in (comment ...) is ignored:
(comment
(parse-port "8080"))
The comment form is useful for scratch expressions, examples, and eval-driven
notes that should remain in the source file but not reach lowering or runtime.
Related Docs#
- data.md - immutable heterogeneous data
- sequences.md - collection helpers and ownership details
- packages.md - shipped
kvist:*package index - odin.md - direct Odin package use
- transforms.md -
deftransform,into,transduce - macros.md - macro authoring
- tooling.md - CLI and editor tooling
- examples/README.md - runnable language and package examples
- Odin Overview - Odin's value, package, pointer, allocator, and procedure model