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):
| Declaration | Meaning |
|---|---|
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 type | Without an initializer |
|---|---|
int / float / bool | 0 / 0.0 / false |
string | "" |
array<T> / map<K, V> | empty, of the declared type |
a class T | default-constructed, as T() |
var, shared_ptr<T>, weak_ptr<T>, function | null |
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).
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);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=3A 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.
int x = 5;
x = 12; // fine, still an int
x = "oops"; // the checker sees a string landing in an intjaiscript: 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:
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);caught: Cannot assign string to 'x' (declared int)
x is still 5The 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
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));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:
auto x = 5; // auto infers int, then enforces it
x = "hello"; // locked to int: string is not convertiblejaiscript: 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:
auto x = 5;
var s = "hello";
try { x = s; } catch (e) { print("caught: ", e); }
print("x is still ", x, " (", type_of(x), ")");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.
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), ")");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:
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));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.
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);p.x = 1, h.x = 7, detached.x = 9var 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.
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);y is 5 (int)
y is now a string (string)
mixed[1] = two
== result: mapContainer 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.
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), ")");declaration: 3.0
parameter: 42!
field: 5 (string)What each target accepts, and what it does with it:
| Target | Accepts | Result |
|---|---|---|
int | int, float, bool, an object with to_int() | float truncates toward zero (3.99 → 3, -3.99 → -3); true → 1 |
float | float, int, bool, an object with to_float() | int widens (3 → 3.0) |
bool | anything | truthiness (5 → true, 0 → false) |
string | anything | stringified (5 → "5"); an object uses its own to_string() when it declares one |
char | char, 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 string | error; there is no implicit parse. Use "42".to_int() |
array / map | array / map | element 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 C | null | stores a typed null |
class C | the same class, or a subclass | a 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 C | shared_ptr<C>, or a handle to a subclass | copies the pointee into the value slot (it keeps its class too) |
class C | a value that exactly one one-argument constructor C(src) takes directly | converts through that constructor |
class C | a 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 null | an 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 null | observes 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) |
function | a function, lambda or bound method, or null | stores 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) |
var | anything, forever | the 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.
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));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:
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);string <- Money: $1234
int <- Money: 1234
Pt <- null: true
Pt <- handle: 0cast<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:
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);
}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):
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);
}copy: 9
alias: 5
shared: 42
caught: Cannot cast 'int' to 'Base'cast<T>(e): a value. A class that isTor derives fromTbecomes a copy, and the copy keeps its own class (acast<Base>of aDerivedis aDerivedcopy).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:
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= bcomputes 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.
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));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:)
int i = 7;
i += 0.5;
var v = 7;
v += 0.5;
print("int: {} ({}) var: {} ({})", i, type_of(i), v, type_of(v));int: 7 (int) var: 7.5 (float)The compound-assignment conversion also applies to typed class fields, typed array elements, and by-ref targets.