The Type Ladder​

Pick your strictness per variable: prototype loosely with var, lock things down with explicit types as the code matures, in the same file, with no rewrite. JaiScript has three rungs of typing strictness for variables (and class fields):

DeclarationMeaning
int x = 5; (any explicit type)Enforced. The variable holds this type forever; assignments convert if the conversion table allows it and error if not.
auto x = 5;Inferred, then enforced. The initializer's static type is inferred once, then locked. From that point auto behaves exactly like the explicit type. auto is not dynamic, unless the initializer's static type is var (below).
var x = 5;Dynamic. Any type may be assigned at any time.

"Error" comes in two flavors, and which one you get depends on what the checker could see. A mismatch it can see (a literal, a typed local, a typed return) is a load error: the script never starts. A mismatch it cannot see (a value that came through a var) is a catchable run-time error. Nothing here is opt-in; the checker is on, in strict mode, from the first line.

Declarations without an initializer​

Value-initialization, as in C++: int x; is 0 and T x; runs T(). Unlike C++, nothing is ever left uninitialized.

A typed declaration always holds a value of its type. Leave the initializer off and it takes the type's default, the way a C++ object is value-initialized:

Declared typeWithout an initializer
int / float / bool0 / 0.0 / false
string""
array<T> / map<K, V>empty, of the declared type
a class Tdefault-constructed, as T()
var, shared_ptr<T>, weak_ptr<T>, functionnull

Parentheses and braces construct with arguments: T x(args); and T x{args}; call a constructor, T x(); and T x{}; default-construct, and a primitive takes one value or its zero (int lives{3};). A class that declares no constructor takes its field values in declaration order instead (Point p{1, 2};, aggregate initialization, in Classes).

jaiscript
class Vec2 { float x = 1.5; float y = 2.5; Vec2() {} Vec2(float a, float b) { x = a; y = b; } }
int hits; float speed; bool alive; string name; array<int> scores; var anything; Vec2 home;
print("hits={} speed={} alive={} name=[{}] scores={} anything={} home=({}, {})", hits, speed, alive, name, scores.size(), type_of(anything), home.x, home.y);
Vec2 a(3.0, 4.0);     // direct-initialization
Vec2 b();             // default construction
int lives{3};         // a primitive takes one value in braces
print("a=({}, {}) b.x={} lives={}", a.x, a.y, b.x, lives);
output
hits=0 speed=0.0 alive=false name=[] scores=0 anything=null home=(1.5, 2.5)
a=(3.0, 4.0) b.x=1.5 lives=3

A class cannot hold itself by value (class Node { Node next; } would build a Node inside every Node, and the parser says so). Spell a link Node next = null;, or share nodes with shared_ptr<Node>.

When a mismatch is caught​

A typed variable holds its type for good. Assign something incompatible and you get an error. The interesting question is when.

jaiscript
int x = 5;
x = 12;          // fine, still an int
x = "oops";      // the checker sees a string landing in an int
output
jaiscript: static check failed:
<script>:3:3: error: cannot assign 'string' to 'x' declared 'int' (declared at 1:5)
    x = "oops";      // the checker sees a string landing in an int
      ^

Nothing ran. The checker walks the whole program first, sees a string literal heading for an int variable, and fails the load. That is strict mode, and it is the default. In an embedded engine the same failure arrives as a thrown jai::static_check_error out of execute().

Now hide the type from the checker. A var is dynamic, so there is nothing to object to at load time and the mistake survives to run time, where it is an ordinary catchable error:

jaiscript
int x = 5;
var v = "oops";          // var hides the type from the checker
try {
    x = v;               // so this one waits for run time - and is catchable
} catch (e) {
    print("caught: ", e);
}
print("x is still ", x);
output
caught: Cannot assign string to 'x' (declared int)
x is still 5

The rejected assignment left x untouched: a failed store never half-writes.

That split is the rule, and it holds for every typed slot in the language, any place a value can land: a variable, a parameter, a return, a field, a container element:

  • A mismatch the checker can see (a literal, a typed local, a typed field, a typed return, an argument to a function the program declares) is a load error. Nothing executes, so there is nothing to catch.
  • A mismatch it cannot see (anything sourced from a var, an unresolved host value or a call through a callable variable) is a run-time error, raised at the offending statement and catchable there.

None of this is opt-in. To loosen it, put [[warn_mode]] at the top of a file, a block, or one statement: the same diagnostics get reported as warnings, and that region loads and runs. extern declares a name the checker cannot see. The full contract is in Types That Fail Early.

auto locks on first use​

jaiscript
auto x = 5;      // auto infers int, then enforces it
x = 2.9;         // float assigned to locked-int: converts (truncates toward zero)
print("x = {} ({})", x, type_of(x));
auto y = 1.5;    // locked to float
y = 2;           // int widens to float
print("y = {} ({})", y, type_of(y));
output
x = 2 (int)
y = 2.0 (float)

Once locked, an incompatible type is an error on the same terms as an explicitly typed variable. A literal fails the load:

jaiscript
auto x = 5;      // auto infers int, then enforces it
x = "hello";     // locked to int: string is not convertible
output
jaiscript: static check failed:
<script>:2:3: error: cannot assign 'string' to 'x' declared 'int' (declared at 1:6)
    x = "hello";     // locked to int: string is not convertible
      ^

…while a var-sourced value slips past the checker and raises where it lands:

jaiscript
auto x = 5;
var s = "hello";
try { x = s; } catch (e) { print("caught: ", e); }
print("x is still ", x, " (", type_of(x), ")");
output
caught: Cannot assign string to 'x' (declared int)
x is still 5 (int)

An uninitialized auto x; locks on its first assignment instead. The checker cannot see which branch assigns first, so it stays out of the way there: a mistake on an uninitialized auto is a run-time error, never a load error.

jaiscript
auto x;                // no initializer: nothing to infer from yet
x = 5;                 // locks to int here, at run time
try { x = "s"; } catch (e) { print("caught: ", e); }
print(x, " (", type_of(x), ")");
output
caught: Cannot assign string to 'x' (declared int)
5 (int)

Once locked, null does not un-lock it either: an int-locked auto rejects null exactly as an int would (the table below has no null row for primitives; class-typed slots do take a typed null).

auto takes the static type​

The type auto locks to is the initializer's static type, the one the checker sees at load, never the type the value happens to hold when the line runs. An initializer whose static type is var or unknown (a var local, an element of an array<var>, a call that returns var, a ternary whose branches differ) has nothing to lock to, so that auto is a var. A ternary with an int branch and a float branch has C++'s common type, float: auto r = hit ? 1 : 0.5; is a float whichever branch runs. The rule is the same for an auto class field and for an auto range-for variable (over a var array its elements have no static type, so the variable is a var). The same line means the same thing on every run:

jaiscript
var row = [1, 2.5, "three"];
auto a = row[0];     // an array<var> element: static type var, so a is a var
a = "now a string";
array<int> ints = [1, 2];
auto b = ints[0];    // static type int: locked to int
b = 2.9;
print("a = {} ({}), b = {} ({})", a, type_of(a), b, type_of(b));
output
a = now a string (string), b = 2 (int)

auto copies a value and shares a handle​

auto y = x; copies what C++ auto copies. When x is a value (a class instance, a bound C++ object held by value, an array, a map), y is a new value made by the copy constructor. When x is a handle (new T(), a shared_ptr<T>, or a host function's std::shared_ptr<T> result), y is a copy of the handle and shares the object. A call that returns a reference, such as a host method returning T&, is copied too, because auto drops the reference as in C++; auto& y = owner.child(); names the object itself. A var and a declaration spelled with a class type copy the same way, and T y = handle; copies the object the handle points at.

jaiscript
class P { int x = 1; }
P p = P();
auto copy = p;          // a value: a new P
copy.x = 5;
auto h = new P();
auto alias = h;         // a handle: the same P
alias.x = 7;
P detached = h;         // a value slot: a copy of the P h points at
detached.x = 9;
print("p.x = {}, h.x = {}, detached.x = {}", p.x, h.x, detached.x);
output
p.x = 1, h.x = 7, detached.x = 9

var is genuinely dynamic​

let is another spelling of var, everywhere a var declaration may stand (let x = 5;, for (let i = 0; ...), let& r = x;). Like every JaiScript declaration it is scoped to its block, as JavaScript's let is.

jaiscript
var y = 5;
print("y is {} ({})", y, type_of(y));
y = "now a string";
print("y is {} ({})", y, type_of(y));
var mixed = [1, "two", 3.14, true];
print("mixed[1] = {}", mixed[1]);
mixed = {"even": "a map now"};
type_of(mixed);
output
y is 5 (int)
y is now a string (string)
mixed[1] = two
== result: map

Container homogeneity follows the ladder: auto nums = [1, 2, 3] locks a homogeneous array; var containers may be heterogeneous and may be reassigned to a different container kind entirely.

The one conversion table​

There is exactly one table, and every typed slot in the language runs it: declarations, =, by-value parameters, returns, instance fields, static fields, and the element boundary of a typed container. Learn it once.

jaiscript
class Tag { string name = "?"; }

string shout(string s) { return s + "!"; }   // parameter AND return run the table

float f = 3;                                 // declaration: int widens
Tag t = Tag();
t.name = 5;                                  // field store: string accepts anything
print("declaration: ", f);
print("parameter:   ", shout(42));
print("field:       ", t.name, " (", type_of(t.name), ")");
output
declaration: 3.0
parameter:   42!
field:       5 (string)

What each target accepts, and what it does with it:

TargetAcceptsResult
intint, float, bool, an object with to_int()float truncates toward zero (3.99 → 3, -3.99 → -3); true → 1
floatfloat, int, bool, an object with to_float()int widens (3 → 3.0)
boolanythingtruthiness (5 → true, 0 → false)
stringanythingstringified (5 → "5"); an object uses its own to_string() when it declares one
charchar, an object with to_char()there is no int row at all, so char c = 65 is an error. cast<char>(65) is the spelling
int / float…but never a stringerror; there is no implicit parse. Use "42".to_int()
array / maparray / mapelement and key types are enforced at the element boundary when a value lands; a literal the checker can see fails the load (array<int> a = ["x"];: array element 0: expected 'int', got 'string'). See Containers & Strings
class Cnullstores a typed null
class Cthe same class, or a subclassa copy that keeps its own class: a Derived in a Base slot is still a Derived, and its overrides run. Parent → child, siblings, and same-shape unrelated classes are rejected
class Cshared_ptr<C>, or a handle to a subclasscopies the pointee into the value slot (it keeps its class too)
class Ca value that exactly one one-argument constructor C(src) takes directlyconverts through that constructor
class Ca class a user operator= of C accepts (plain assignment only)runs your operator
shared_ptr<C>an instance or handle of C or a subclass, or nullan instance becomes a new shared object; null stores a typed null; any other class is rejected
weak_ptr<C>a handle of C or a subclass, or nullobserves the handle's object; null stores a typed null; an instance and any other class are rejected
T&an lvalue that holds a T (for a class: T or a subclass)binds without a copy; anything else is an error (Value Semantics)
functiona function, lambda or bound method, or nullstores it; anything else is an error (Cannot assign int to 'cb' (declared function)), as a std::function takes only a callable. An instance with operator() is callable but is not a function value, so it raises too (Cannot initialize 'f' (declared function) with Adder): wrap it in a lambda (Classes)
varanything, foreverthe opt-out, and everything computed from a var is invisible to the checker too

Conversions never chain. One step is the whole budget. A class with to_int() reaches an int slot, but it will not go through to_int() and then through some other class's C(int) constructor to reach a C slot. And if a converting constructor or a to_X() throws on the way, you see its error, not the table's.

jaiscript
float f = 3;         // int -> float widens
print("f = {} ({})", f, type_of(f));
int i = 3.99;        // float -> int truncates toward zero
print("i = {} ({})", i, type_of(i));
int j = -3.99;
print("j = {}", j);
bool b = 5;          // numeric -> bool is truthiness
print("b = {}", b);
string s = 5;        // int -> string coercion on typed target
print("s = {} ({})", s, type_of(s));
output
f = 3.0 (float)
i = 3 (int)
j = -3
b = true
s = 5 (string)

Objects reach primitive slots the same way, through the conversions they declare:

jaiscript
class Money {
    int cents = 0;
    Money(int c) { cents = c; }
    string to_string() { return format("${}", cents); }
    int to_int() { return cents; }
}
string s = Money(1234);      // string <- anything; objects use their to_string()
print("string <- Money:  ", s);
int n = Money(1234);         // int <- an object with to_int()
print("int    <- Money:  ", n);

class Pt { int x = 0; }
Pt p = null;                 // object <- null: a typed null
print("Pt     <- null:   ", p == null);
shared_ptr<Pt> h = new Pt();   // new makes a handle (Chapter 3)
Pt q = h;                    // object <- shared_ptr<Pt>: copies the pointee
print("Pt     <- handle: ", q.x);
output
string <- Money:  $1234
int    <- Money:  1234
Pt     <- null:   true
Pt     <- handle: 0

cast<T>(e): the explicit boundary​

Some conversions the table refuses on purpose, not because they are wrong, but because you should have to say so. cast is where you say so. It is a keyword, not a function.

cast<T>(e) yields exactly what T tmp = e; would hold. For primitives that is the table above, run on demand:

jaiscript
print("cast<int>(2.7)    = ", cast<int>(2.7));     // the assignment table, spelled out
print("cast<string>(5)   = ", cast<string>(5));
print("cast<int>('a')    = ", cast<int>('a'));     // char converts ONLY through cast
print("cast<char>(97)    = ", cast<char>(97));     // range-checked 0..255
print("cast<char>(\"a\")   = ", cast<char>("a"));
try {
    var bad = cast<char>(256);
} catch (e) {
    print("caught: ", e);
}
output
cast<int>(2.7)    = 2
cast<string>(5)   = 5
cast<int>('a')    = 97
cast<char>(97)    = a
cast<char>("a")   = a
caught: Cannot cast 'int' to 'char'

For a class there are three forms, and they differ exactly the way the C++ spellings differ (copy, alias, share):

jaiscript
class Base { int a = 1; }
class Derived : Base { int t = 9; }

shared_ptr<Derived> h = new Derived();
print("copy:   ", cast<Derived>(h).t);       // a VALUE copy tagged Derived
cast<Derived&>(h).t = 5;                     // a reference to the SAME object
print("alias:  ", h.t);
shared_ptr<Base> b = cast<shared_ptr<Base>>(h);
b.a = 42;                                    // shares - same object, base spelling
print("shared: ", h.a);
try {
    var oops = cast<Base>(5);
} catch (e) {
    print("caught: ", e);
}
output
copy:   9
alias:  5
shared: 42
caught: Cannot cast 'int' to 'Base'
  • cast<T>(e): a value. A class that is T or derives from T becomes a copy, and the copy keeps its own class (a cast<Base> of a Derived is a Derived copy).
  • cast<T&>(e): a reference to the same object. Member reads and stores go through it, which makes it the strict-mode fix for reaching a subtype's member on a base-typed receiver (see Value Semantics).
  • cast<shared_ptr<T>>(e): a handle sharing the same object.

A class mismatch raises the catchable Cannot cast 'X' to 'T', where X is the object's dynamic class. Upcasts always pass. null passes the value and handle forms and fails the reference form. A T that names no type at all is a load error (unknown type in cast).

The value form applied to a handle copies, which is rarely what you meant, so the checker says so, at warning severity. Warnings never gate the load; the runner prints them when you pass --warnings, and a host reads them from last_check_diagnostics(). Line 5 of the sample above draws this one:

output · --warnings
jaiscript: static check warnings:
try.jai:5:19: warning: cast<Derived>(h) copies the instance - use cast<Derived&>(h) to alias it or cast<shared_ptr<Derived>>(h) to share it (declared at 4:21)
    print("copy:   ", cast<Derived>(h).t);       // a VALUE copy tagged Derived
                      ^

Compound assignment: typed targets convert C++-style, var keeps the dynamic result​

The rule is two-sided:

  • On a typed (or locked-auto) target, a op= b computes like C++ and converts the result back to the target's type. int i = 7; i /= 2 → 3; i += 0.5 → 7 (7.5 truncated back).
  • On a var target, the stored value is whatever the operation produces. The variable may change type.
jaiscript
int i = 7;
i /= 2;              // C++-style on typed target: stays int
print("int 7 /= 2 -> {} ({})", i, type_of(i));
i = -7;
i /= 2;              // truncation is toward zero
print("int -7 /= 2 -> {}", i);
float f = 7.0;
f /= 2;
print("float 7.0 /= 2 -> {}", f);
auto a = 7;          // auto locked to int behaves like int
a /= 2;
print("auto(int) 7 /= 2 -> {} ({})", a, type_of(a));
var v = 7;           // var keeps dynamic behavior: result may change type
v /= 2;
print("var 7 /= 2 -> {} ({})", v, type_of(v));
output
int 7 /= 2 -> 3 (int)
int -7 /= 2 -> -3
float 7.0 /= 2 -> 3.5
auto(int) 7 /= 2 -> 3 (int)
var 7 /= 2 -> 3 (int)

(var v = 7; v /= 2 gives 3 because 7 / 2 is integer division; the type difference shows with a float operand:)

jaiscript
int i = 7;
i += 0.5;
var v = 7;
v += 0.5;
print("int: {} ({})  var: {} ({})", i, type_of(i), v, type_of(v));
output
int: 7 (int)  var: 7.5 (float)

The compound-assignment conversion also applies to typed class fields, typed array elements, and by-ref targets.