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.