CX+AI

CX Coercion Guide

Beginner → advanced: how a value becomes the type you asked for

GENERATED from DOCS/reference/manuals/CX_Coercion_Guide.html by tests/forge.cx — edit the source, not this page.

Coercion in CX

You declare the type you want. The value arrives as that type. This one idea removes most of the conversion ceremony that config-driven and AI-driven code usually demands — and it stays literally C underneath.

Sections 1–5 are the beginner path and assume nothing but C basics. Sections 6–10 are the advanced path: the actual rules, the machinery, and the sharp edges. Every claim here was verified against the shipped compiler by running it.


1. The one idea

Most languages make you convert. You read something loosely typed — a config value, a JSON field, a reply from a model — then write the conversion by hand, once per use, and get it wrong somewhere.

CX inverts that. The destination decides. Whatever you are assigning into — a variable's declared type, a function's parameter, a return type, the other side of an operator — is called the sink, and the sink's type is what the value is read as.

json cfg;
cfg = jsonParse("{ \"port\": 8080, \"host\": \"localhost\", \"ratio\": 0.75 }");

port.i  = cfg["port"];    // read as int    -> 8080
host.s  = cfg["host"];    // read as string -> "localhost"
ratio.f = cfg["ratio"];   // read as float  -> 0.75

Three different reads of the same document, and not one conversion call. The .i, .s and .f suffixes you already write on every declaration do the work. There is no runtime guessing: the compiler knew the sink's type, so it picked the right read at compile time.


2. Numbers: int and float

Start with the part that will not surprise you. Between .i (64-bit int) and .f (double), CX follows C's usual arithmetic conversions:

n.i = 7;
f.f = 2.0;

a.f = n / 2;      // int / int = int division -> 3, then widened to 3.0
b.f = n / 2.0;    // one operand is float -> float division -> 3.5
c.f = n * f;      // mixed -> float -> 14.0
d.i = f;          // float into an int sink -> truncates -> 2
If you know C, you already know this section. Integer division stays integer division; promotion happens when an operand is float. CX did not invent a cleverer rule here on purpose — surprising arithmetic is not a feature.

3. Numbers into text

When a string is one side of a +, the other side is converted to text:

n.i   = 42;
ms.f  = 12.5;

msg.s  = "answer = " + n;        // "answer = 42"
line.s = "took " + ms + " ms";   // "took 12.50 ms"
This works, but it warns — and the warning is asking a fair question. Both lines raise CX-W1004: string '+' with a non-string: the value is auto-converted to text -- write str(...) to be explicit. The conversion happens and the output is correct; the compiler just wants you to say you meant it, because + could as easily have been arithmetic.

Wrap the value in str() and the warning goes away:

msg.s = "answer = " + str(n);            // no warning
println("count: " + str(q->count));

str(x) is also what you want when there is no string sibling to trigger the conversion at all: println(str(n));

Float formatting is a pragma, not a guess. #pragma decimals 2 sets how many decimal places a float renders with, which is why 12.5 printed as 12.50 above. Set it once at the top of the file.

4. JSON: where it pays off

A JSON value has no inherent scalar type until something asks for one. That is not a weakness — it is exactly why sink-driven reading works so well.

Most languages need a conversion at every read:

port = int(cfg["port"])
host = str(cfg["host"])
rate = float(cfg["rate"])

CX needs none, because you already declared what you wanted:

port.i = cfg["port"];
host.s = cfg["host"];
rate.f = cfg["rate"];
hp.i   = cfg["hp"] + 10;

And it is not only declarations. Every typed destination behaves the same way — that consistency is the point, because a rule with exceptions is a rule you have to remember:

function takeDamage.v(dmg.i) { hp = hp - dmg; }

hp.i = 0;

hp = cfg["hp"];                  // assignment sink
hp += cfg["bonus"];              // compound-assign sink
takeDamage(cfg["dmg"]);          // parameter sink (the param's declared type)
listAdd(scores, cfg["score"]);   // container element sink (the list's element type)
queuePush(dmg, cfg["hit"]);      // same for a queue

function baseHp.i() {
   return cfg["hp"];             // return sink -> read as int
}

The same applies to lookup(), the fast named-value read used by the rules engine — it is typed by its sink through the identical hook, not a parallel mechanism.

The sink reaches it through a wrapper, too (v3.179.25). Unary minus, the arithmetic operators and a ternary's arms pass a number along unchanged, so they pass the sink along with it:

printf("%lld\n", lookup(e, "big") * 1);   // still the exact int64 lane
printf("%lld\n", -lookup(e, "big"));      // exact, negated

One rule decides where that stops: a statically-float sibling outranks the sink. (c ? lookup(e, "flt") : 1) * 100.0 reads 2.25 and multiplies, even inside an (int) cast — the multiply is float arithmetic, and the cast rounds the result, which is what a cast is for. Anything that is not one of those wrappers — a call, a comparison, a concatenation, a subscript — is a consumer with its own typing, and the sink stops there.


5. Value vs entity — the one distinction to learn

If you remember one thing beyond "the sink decides", make it this. A json expression can be two different kinds of thing:

ShapeWhat it isWhat happens
doc["key"] — a subscripta value inside the documentcoerced to whatever the sink wants
doc — a bare json variablean entity handle, the document itselfpassed through untouched
json doc;

n.i = doc["hp"];   // VALUE  -> coerced to int
doc->count;        // ENTITY -> the document handle is passed as-is
arm(doc, pre);     // ENTITY -> the rules engine wants the document

This is why the line is drawn at value vs entity rather than at json vs not-json: coercing a bare document to a number would turn every rules-engine and persistence call into nonsense. A document is a thing; a field is a value.


6. The coercion table

There is exactly one table in the compiler that answers which conversion applies, and both backends read it. Per-site guards decide whether to coerce; this decides only how:

Source→ int→ float→ string
json valuecx_json_as_intcx_json_as_floatcx_json_as_str
variantcx_var_as_intcx_var_as_floatcx_var_as_str
int / floatC's usual arithmetic conversionsbi_str_int / bi_str_flt
Why one table matters. A second conversion path is how backends drift apart — the native build and the VM would answer differently for the same source, and only one of them would be tested. Everything that coerces routes through here, so native and VM agree by construction.

7. Two directions: sink-driven and sibling-driven

Everything in the beginner half was sink-driven: a declared destination existed, so its type won. Inside an operator there is no declaration to read, so a second rule takes over.

Sibling-driven: text or number, decided by the neighbour

n.i = 10;   f.f = 2.5;   s.s = "hp: ";

a.f = doc["hp"] + n;    // numeric sibling -> read as a NUMBER (lossless)
b.f = doc["hp"] + f;    // numeric sibling -> read as a NUMBER (lossless)
c.s = s + doc["hp"];    // string sibling  -> read as TEXT, concatenated
if (doc["hp"] > 50) { } // compared numerically

The sibling decides text versus number. It does not decide how precise the number is: a json field read numerically is always read losslessly, as a float.

Changed in v3.179.20 / v3.179.21 — this supersedes the old int bias. Previously an int sibling made the field read as an int, which threw the fraction away before the operator ever ran. With "scale": 2.5, doc["scale"] * 2 answered 4, and if (doc["scale"] > 2) answered false — a silent wrong boolean, not a rounding difference. The sibling had no way of knowing what was in the field, so it was never the right thing to ask.

The visible cost of that fix is that json arithmetic now types float, even when the stored value is whole:

println(str(doc["level"] * 2));   // "14.000", not "14", for level = 7

Use a typed sink or a cast when you want the integer shape back — both land exactly, in either direction:

lv.i = doc["level"] * 2;          // 14
println(str((int)(doc["level"] * 2)));  // "14"

Bitwise and shift operators (&, |, ^, <<, >>) are integral and are not affected. lookup() on a rules/persist lane is also unaffected: those lanes are declared with a type, so the declaration names the read and an int lane keeps its full 64-bit exactness.

When both sides are json

With no concrete sibling to take a type from, the pair defaults to numeric — and because + could plausibly have meant concatenation, the compiler warns rather than choosing in silence:

x = doc["a"] + doc["b"];        // numeric addition + a warning: say which you meant
x = doc["a"] + str(doc["b"]);   // explicit concatenation, no warning

Ternaries coerce per arm

v.f = flag ? doc["hp"] : 100;   // the json arm is coerced; the literal arm is left alone

The coercion is applied to the json arm only, not wrapped around the whole expression — wrapping the result would misread the arm that was already an int.

The scalar arm names text versus number for the json arm, but not its precision — same rule as a binop, for the same reason. Before v3.179.21 an int arm made flag ? doc["hp"] : 100 read 2.5 as 2.

Unary minus reads the value, not the handle

d.f = -doc["delta"];            // -2.5 for a stored 2.5

There is no sibling here at all, so nothing named a type — and before v3.179.21 that meant the node handle was negated rather than the value. Worth knowing as a shape: whenever a json value sits somewhere with no neighbour and no declaration, the lossless numeric read is the default that applies.


8. Builtin arguments: json-aware vs json-blind

Some builtins want the raw node handle because they dispatch on the node's kind themselves — len(), str(), and the whole json* family. Others have no idea JSON exists: abs(), max(), sqrt() take numbers. Hand a node handle to one of those and, since a handle is a valid integer, C would happily compute on the node index — a silently wrong answer.

The fix avoids keeping a list of json-aware builtins, because a second list drifts from the first. Instead the compiler asks its own builtin resolver twice: once pretending the first argument is json, once pretending it is a number. If the resolver picks a different C function, the builtin is json-aware and gets the handle untouched. If it picks the same one, the builtin is blind to json and the argument is coerced to a number first.

len(doc["items"])   // json-aware -> cx_json_len    -> handle passed through
str(doc["name"])    // json-aware -> cx_json_as_str -> handle passed through
abs(doc["delta"])   // json-blind -> same fn either way -> argument coerced
sqrt(doc["area"])   // json-blind -> coerced

Why this design is worth copying: it self-maintains. Teach some builtin to handle json later and the coercion steps aside automatically, with nothing else to update — because the fact lives in one resolver rather than being duplicated into a table someone has to remember to edit.

A json value here is read as a float

A blind builtin's argument is read with the float reader, because a json member's type is not knowable when the code is compiled and a float is the lossless choice for both of JSON's number lanes. That read also decides the builtin's own shape: for the arg-polymorphic family (abs, min, max, clamp), a json argument selects the float variant, exactly as a literal 2.5 would.

cfg = jsonParse("{ \"area\": 2.25, \"delta\": -4.5, \"hp\": 7 }");

sqrt(cfg["area"])    // -> 1.5    the fraction survives
abs(cfg["delta"])    // -> 4.5    float variant, because the json arg is read as one
max(cfg["area"], 1)  // -> 2.25

n.i = abs(cfg["hp"]);  // -> 7    an int member is exact; the sink puts it back on the int lane

So str(abs(cfg["delta"])) prints 4.500, not 4 — the call's type follows the same vote, not just its value. If you want the integer, ask for it: str((int)abs(...)), or read through an .i sink.

Fixed in v3.179.15. Before that, a json argument did not count as float when the int-vs-float variant was chosen, so the int variant won — and the argument was then read as an int to match that choice. Two halves agreeing on the same wrong premise: sqrt(cfg["area"]) with 2.25 answered 1.4142 (that is sqrt(2)) and abs(cfg["delta"]) with −4.5 answered 4. Wrong answers, not rounding. On v3.179.0 and earlier, read the field into an .f sink first.

json-aware builtins (len, str, the json* family) are unaffected — they never take the coerced path at all — and neither is a bare json variable, which is an entity (§5).

⚠ The int64 lane does not survive this position. A json integer above 253 (a large id) read through a blind builtin goes through a double and loses exactness — the one case the float read cannot serve, because the argument's C type has to be decided before the value is known. Measured: ``cx js = jsonParse("{ \"big\": 9007199254740993 }"); b.i = js["big"]; // -> 9007199254740993 exact, the int lane max(js["big"], 1) // -> 9007199254740992 off by one ` Keep such a field on the int lane: read it into an .i sink and pass *that*, rather than subscripting inside the call. **Since v3.179.27 this loss announces itself** — CX-W5005, once per program run, naming the value and the .i escape. It is a warning, never a fatal: the answer is the honest one available to a single static argument type, and you are the one who knows whether this particular field needs the int lane. An .i sink or (int)` cast is silent because it reads through a different (exact) path, not because the warning was suppressed.

A json string at a builtin that wants a string

Since v3.179.26 the argument position asks what the builtin's parameter is, so a json string lands correctly in the string family:

instr(js["s"], "cd")        // 3
ucase(js["s"])              // "ABCDEF"
replacestring(js["s"], "ab", "Z")

Before that, only the numeric question was asked, and a cx_float was handed to a cx_str_handle parameter: native failed the generated C build (loud, but at the wrong layer), and the register VM answered silently wronginstr 0, ucase "". If you are on an older build, read the field into an .s sink and pass that.

Which positions are strings comes from the builtin's own C prototype, so the numeric positions of a mixed builtin are unaffected — mid(js["s"], 2, 3) reads the subscript as text and 2, 3 as numbers, and stringfield(s, n, sep) treats only arguments 1 and 3 as strings.


9. variant — the runtime-typed lane

variant is a value whose type is only known at runtime. It appears where CX genuinely cannot know in advance: values crossing an FFI boundary, and values produced by a model. It coerces through the same table as json, so an AI-produced value works in arithmetic, concatenation and comparison exactly like a json field does.

Two things to know:

It is also the slow path, and the compiler will tell you so — a note naming the variable, because a value on the tagged path is coerced at every single use rather than once at compile time. If you see that note on something you expected to be resolved, it is a hint worth chasing.

10. Sharp edges worth knowing

Comparing a document to zero

A container document compared against a number does not coerce to 0. It used to, which collided with the null-handle sentinel and made doc == 0 come out true for a perfectly good object. Equality between a json operand and a numeric sibling now routes through a dedicated comparison:

if (doc == 0) { }              // false for a real object/array -- as you would expect
if (doc["missing"] == 0) { }   // null / number / bool / string compare as before

Only the array/object case changed. Relational operators (<, >) keep the plain numeric coercion.

Since v3.180.0 there is a field that asks the question directly, which is worth preferring where you mean "is this handle usable":

if (doc->valid) { }            // 1 while live, 0 after a jsonFree

doc != 0 and doc->valid are not the same test. A freed handle is still a non-zero number, so only the second one can see it. (It catches use-after-free, not use-after-free-then-reuse — a pool slot can be recycled by a later parse.)

JSON has two number lanes

A value that is statically an integer is stored in an int64 lane rather than round-tripped through a double, because a double loses exactness above 253. This matters if you carry large ids in JSON — they survive. Read such a field into an .i sink to keep that exactness; reading it into .f converts, as you asked it to.

Reading a container consumes it

Not coercion, but the same class of surprise: queueTake() and queuePop() remove the element they return. Guard with q->count > 0 rather than probing by failing — an empty read is a loud error, not a zero.

What is never coerced

``cx int n; n = "42"; // CX-E1074 -- refused, not silently 0 n = val("42"); // 42 -- the explicit door float f; f = valf("2.5"); // 2.5 ``

The refusal is deliberate rather than a missing feature. The reverse direction is lossless and does convert silently: a number reaching a declared string sink becomes text through bi_str_int / bi_str_flt (§3). Conversion where the data speaks losslessly, a refusal where it does not — that asymmetry is the whole of the rule.


11. Recap

SituationWhat decides the type
Declaration, assignment, compound-assignthe declared type of the destination
Function argumentthe parameter's declared type
returnthe function's return suffix
Container add / pushthe container's declared element type
Operator with a concrete siblingthe sibling's type
Operator, both sides jsonnumeric, and + warns
Builtin argumentjson-aware builtins get the handle; blind ones get a float — which also picks the builtin's float variant (§8)
Bare json / container variablenothing — it is an entity, passed through
The rule underneath all of it: a loosely-typed value has no type until something asks for one, and in CX the thing asking is almost always a type you already had to write. That is why the ceremony disappears without any magic being introduced — the information was already in your source.