Everything is a string
kaiv has exactly one primitive type. On floats that are really decimals, integers that survive 2^53, a money type in three lines, and what a type system looks like when every type is a set of spellings.
Ask a JavaScript runtime to read a perfectly ordinary JSON document:
$ node -p 'JSON.parse("{\"total\":9007199254740993}").total'
9007199254740992
The document was fine. The number in it was exact — sixteen
digits, written out in full. What broke it was the type system
it landed in: JSON’s number is whatever IEEE 754 double the
host hands you, 9007199254740993 is 2^53 + 1, and doubles run
out of integers at 2^53. So the parser rounds. No error, no
warning, off by one — and the one it dropped might have been a
database key.
This is not a JavaScript bug. It is what happens whenever a data format types its numbers as machine numbers: every property of the machine representation — the 53-bit mantissa, the binary fractions that can’t spell 0.1, the negative zero, the NaN that isn’t equal to itself — becomes a property of your data. The format didn’t mean to import any of that. It just said “number” and pointed at the hardware.
kaiv types numbers as spellings. A value in a kaiv document
is a string, full stop — and so is every other value, because
kaiv has exactly one primitive type, and it is str. This
sounds like an evasion. It is actually the type system.
The whole primitive zoo, in one small file
Here is std/core.taiv, the library that defines kaiv’s
“built-in” types. Not an excerpt — the entire file, exactly as
it ships embedded in the toolchain:
.!taiv 1 std/core
// Integer — decimal string with numeric ordering
/^-?[0-9]+$/ ..num
&int=
// Floating-point number — decimal string with numeric ordering
/^-?[0-9]*\.?[0-9]+([eE][+-]?[0-9]+)?$/ ..num
&float=
// Boolean — two-valued enumeration
{true,false}
&bool=
// Null — empty value only
/^$/
&null=
// Base64url-encoded binary data (RFC 4648 section 5, unpadded)
/^[A-Za-z0-9_-]*$/
&b64=
// Multi-line text in readable form — the value is a |:|-separated
// sequence of lines. The separator is interpreted only at the
// application/export layer; within kaiv the value is verbatim.
&text=
int is not a language primitive. It is a library type: a
pattern (decimal digits, optional sign) and an ordering
(..num, numeric). bool is an enumeration of two spellings.
null is the type whose only member is the empty string. And
text is the honest extreme: no constraint at all — any string
— just a name that tells the export layer to read each |:| as
a newline. Multi-line text is not a second primitive; it is a
reading of the one there is. The
design follows C’s #include <stdint.h> — the types you think
of as built-in arrive through a header — except kaiv takes it
all the way down: even int itself is an import.
Which means there is nothing underneath but str, and one
mechanism doing all the work.
A type is a constraint triple
Every kaiv type — library or inline — is a constraint triple: a pattern (which spellings belong), a span (how members are ordered), and a range (bounds in that order). Validation is the only operation: does this string match the pattern, and does it fall inside the range under the span’s ordering?
You can watch the triple at work, because the validator names it when a value fails. Take a ledger and a schema that caps its total at exactly 2^53:
$ cat ledger.kaiv
.!kaiv
total=9007199254740993
$ cat ledger.saiv
.!saiv 1 demo/ledger
!int[0,9007199254740992]
total=
$ kaiv validate ledger.kaiv ledger.saiv
kaiv: ConstraintViolationError: ::total=9007199254740993 (type !str) violates /^-?[0-9]+$/ ..num [0,9007199254740992] (line 2)
Read the message: the value is (type !str) — a string, like
every value — and what it violates is not “int” but
/^-?[0-9]+$/ ..num [0,9007199254740992]: the pattern, the
span, the range. The error is an x-ray of the type system.
!int was never anything more than this triple; the validator
just unfolded the name.
And notice why it failed: 9007199254740993 is genuinely
outside [0, 9007199254740992] — by one. The comparison is
exact. Loosen the bound by that one:
$ printf '.!saiv 1 demo/ledger\n\n!int[0,9007199254740993]\ntotal=\n' > roomy.saiv
$ kaiv validate ledger.kaiv roomy.saiv
pass
The spec is explicit here: for int-derived types, ..num
comparison is exact integer comparison at arbitrary precision —
“no silent truncation at 2^53.” There is no truncation because
there is nothing to truncate into: the comparison walks
digits, the way you would compare the numbers on paper. The
runtime that rounded this value to read it is comparing floats.
kaiv is comparing numerals.
Identity is spelling; order is numeric
A string-rooted type system has to be honest about a question
that machine-number systems blur: is 1.50 the same value as
1.5?
kaiv’s answer: they are equal in the order and distinct in identity — and it gives you both relations, explicitly:
$ cat x.kaiv
.!kaiv
x=1.50
$ kaiv validate x.kaiv range.saiv
pass
$ kaiv validate x.kaiv enum.saiv
kaiv: ConstraintViolationError: ::x=1.50 (type !str) violates /^-?[0-9]*\.?[0-9]+([eE][+-]?[0-9]+)?$/ ..num {1.5} (line 2)
range.saiv constrains x to !float[1.5,1.5] — a range —
and 1.50 passes, because under the ..num ordering it sits
exactly on the boundary: ranges compare numerically. The enum
schema constrains x to !float{1.5} — a set of members — and
1.50 fails, because membership is spelling: 1.50 and 1.5
are different strings. Ordering answers “where does this value
fall?”; identity answers “is this that exact value?” — and in a
format where the value is its spelling, those are honestly
different questions with honestly different answers. Formats
that convert first answer only the first question, and then
pretend it was also the second.
(One boundary the spec pins rather than hides: range checks on
float-shaped operands compare as IEEE 754 doubles — the
comparison domain, chosen and named. The value never passes
through a double. Nothing is rewritten, rounded, or
re-serialized; the string that arrived is the string that
ships. int-shaped operands, as above, compare exactly at any
width.)
A money type in three lines
The classic advice — never put money in a float — exists because the machine-number types can’t spell prices. In kaiv, saying exactly what a price is takes a pattern and a span:
$ cat demo/money.taiv
.!taiv 1 demo/money
// Exact decimal money: two digits after the point, always
/^-?[0-9]+\.[0-9]{2}$/ ..num
&price=
A schema imports it like any other library and uses it like any other type:
$ cat invoice.saiv
.!saiv 1 demo/invoice
.!registry demo=.
.!types demo/money
&price
total=
$ kaiv validate inv.kaiv invoice.saiv
pass
$ printf '.!kaiv\ntotal=19.9\n' > oops.kaiv
$ kaiv validate oops.kaiv invoice.saiv
kaiv: ConstraintViolationError: ::total=19.9 (type !str) violates /^-?[0-9]+\.[0-9]{2}$/ ..num (line 2)
19.99 is a price. 19.9 is not — not “close enough,”
rejected. And the number that float arithmetic is famous for?
$ node -p '0.1 + 0.2'
0.30000000000000004
$ printf '.!kaiv\ntotal=0.30000000000000004\n' > sum.kaiv
$ kaiv validate sum.kaiv invoice.saiv
kaiv: ConstraintViolationError: ::total=0.30000000000000004 (type !str) violates /^-?[0-9]+\.[0-9]{2}$/ ..num (line 2)
&price will simply never admit it. A value like that cannot
occur in a validated document — not because anyone rounds it
away, but because seventeen decimal places don’t match the
pattern. The type doesn’t sanitize float residue; it makes
float residue unspellable. Infinity and NaN get the same
treatment from !float itself: inf doesn’t match the
pattern, so it never reaches a range check. (If your domain
genuinely wants infinities, std/num defines them as types —
opt-in, by name, not ambient.)
Same carrier, other orders
Once a type is (pattern, span, range) over strings, “numeric” stops being special. It is just one span among several — swap the ordering and the same machinery types other things:
$ printf '.!kaiv\nengine=1.10.0\n' > v.kaiv
$ printf '.!saiv 1 demo/v\n\n..ver[1.9.0,]\nengine=\n' > ver.saiv
$ kaiv validate v.kaiv ver.saiv
pass
..ver orders by dotted segments, numerically — so 1.10.0
is correctly after 1.9.0, where byte order would have
sorted it before. ..time orders ISO 8601 instants
chronologically. ..lex is plain byte order, and
..lex[locale] is locale collation under CLDR — the one span
where two validators could ever disagree, which is why the
spec pins the collation version and kaiv fences it off as its
own conformance level. Every one of these is the same story:
strings, an ordering, a range. No span converts the value into
anything; each one just defines how spellings compare.
Types as languages
Here is the part that is theoretically pleasing, stated
plainly. A kaiv type denotes a set of strings — a language over
one alphabet. Validation is the membership question. Subtyping
is inclusion, and you can see it in std/net: every port is
an int (the pattern tightens, the range narrows), and every
int is a str. Refining a type is intersecting it with
another constraint; the intersection of two triples is a
triple. There are no coercions anywhere in the system, because
there is nothing to coerce between — one carrier set, and
types that are just ever-smaller subsets of it.
This is the refinement-types picture with the base collapsed to a single type, or the “types as predicates” slogan taken unusually literally by a wire format. Is there a paper in it? Probably not much of one — the pieces (regular languages, refinement by predicate, one universe) are each old. But you rarely get to stand somewhere so clean: a full practical type system — integers, floats, booleans, money, versions, timestamps — with one primitive, one type constructor, and membership as its only judgment.
What the string buys you at the exit
kaiv does no arithmetic — a validator computes nothing, which is precisely why it can promise to change nothing. So the guarantee at the exit is the same as at the entrance: the spelling that arrived is the spelling that leaves, and export carries it out intact:
$ printf '.!kaiv\n!int\ntotal=9007199254740993\n' > typed.kaiv
$ kaiv export --json typed.kaiv
{"total":9007199254740993}
All sixteen digits, and — look again at the opening of this
article — this JSON text was never the problem. JSON the
format spells numbers in decimal, exactly, arbitrarily wide;
it was the type the parser assigned that shattered the
value. Hand this line to Python’s int, to a Rust
arbitrary-precision reader, to anything that reads digits as
digits, and it survives. kaiv can’t fix the parser on the
other side. It can guarantee that every digit reaches it —
because from the moment the value was written to the moment it
was exported, nothing in the pipeline ever held it as anything
but the string you spelled.
kaiv is an immutable structural type system for data at rest. The User Manual is the accessible tour; the specification § 2.6 defines the constraint triple and the numeric domain precisely; the playground runs the whole toolchain — including every example above — in your browser.