Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tolk v0.9: nullable types T?, null safety, control flow, smart casts #1545

Merged
merged 5 commits into from
Mar 5, 2025

Conversation

tolk-vm
Copy link
Contributor

@tolk-vm tolk-vm commented Mar 4, 2025

This is a major update that impacts both end users and compiler internals. In FunC, null was implicitly assignable to any primitive type — too permissive. A variable declared as int could actually hold null at runtime — causing TVM exceptions if used incorrectly. Similarly, loadMaybeRef() returned cell, but that cell could be null, very unobvious from its prototype.

Now, we introduce nullable types: int?, cell?, and T? in general (even for tensors). Non-nullable types, such as int and cell, can never hold null values.

The compiler now enforces null safety: you cannot use nullable types without first checking for null. Fortunately, thanks to smart casts, these checks integrate smoothly and organically into the code. Smart casts are purely a compile-time feature — they do not consume gas or extra stack space, ensuring zero runtime overhead.

Notable changes in Tolk v0.9

  1. Nullable types int?, cell?, etc.; null safety
  2. Standard library (asm definitions) updated to reflect nullability
  3. Smart casts, like in TypeScript in Kotlin (implemented via control flow graph)
  4. Operator ! (non-null assertion)
  5. Code after throw is treated unreachable
  6. The never type

Now, let's cover every bullet in detail, and see how it's implemented.

Nullable types int?, cell?, etc. Null safety

This update introduces nullable types T?, allowing values to be explicitly marked as nullable while preventing their use without a null check. This strictness aligns with TypeScript and Kotlin (where TypeScript uses T | null, we use T?) and ensures that null cannot be accidentally passed into storeInt(), or used in operations without proper handling.

var value = x > 0 ? 1 : null;  // int?

value + 5;               // error
s.storeInt(value);       // error

if (value != null) {
    value + 5;           // ok
    s.storeInt(value);   // ok
}

Key Features

  • Any type can be made nullable: cell?, [int, slice]?, (int, cell)?, (int?, cell?)?, and so on — each guaranteeing safe usage.
  • No unexpected null values: previously, int could implicitly hold null, leading to runtime errors. Now, non-nullable types always contain a value.
  • Lightweight and efficient: at the TVM level, int? and cell? occupy only one stack slot — either storing a value or TVM NULL at runtime. This is identical to how int and cell worked in FunC, ensuring zero additional overhead.

Handling nullable tensors is more complex. In FunC, null was incompatible with tensors — it could only be assigned to atomic types. However, this update extends nullability to tensors while ensuring memory and stack correctness. (See implementation details below)

When return type of a function is not specified, it's automatically inferred based on return statements, which can contain nulls:

// will be auto inferred `builder?`
fun createFor(x: int) {
    if (x < 0) {
        return null;
    }
    return beginCell();
}

Remember, that when a variable's type is not specified, it's auto inferred from assignment and never changes:

var i = 0;
i = null;       // error, can't assign `null` to `int`
i = maybeInt;   // error, can't assign `int?` to `int`

Previously, such a code worked:

var i = null;
if (...) {
    i = 0;
}

Now, you must explicitly declare the variable as nullable:

var i: int? = null;
// or
var i = null as int?;

From a type system perspective, the literal null has a special null type. This applies everywhere, including global variables and tensor indices:

var t = (1, 2, 3);
t.0 = null;          // error

var u: [int, int?] = ...;
u.1 = null;          // ok
u.0 = null;          // error

This is a major step forward for type safety and reliability. Nullable types eliminate runtime errors, enforcing correct handling of optional values. The implementation is lightweight, with zero additional gas or stack usage.

Updates in stdlib to reflect nullability

Now it's obvious whether an assembler function accepts or returns null value:

@pure
fun loadRef(mutate self: slice): cell
    asm( -> 1 0) "LDREF";

@pure
fun loadMaybeRef(mutate self: slice): cell?
    asm( -> 1 0) "LDOPTREF";

@pure
fun getCellDepth(c: cell?): int
    asm "CDEPTH";


var r = s.loadMaybeRef();   // r: cell?
r.beginParse();             // error
b.storeRef(r);              // error
r.getCellDepth();           // ok, can pass null

if (r != null) {
    r.beginParse();         // ok
    b.storeRef(r);          // ok
    r.getCellDepth();       // also ok
}

r = s.loadRef();            // r: cell
r.beginParse();             // ok
b.storeRef(r);              // ok

Smart casts, implemented via control flow graph

With the introduction of nullable types, we want to allow intuitive handling of nullability, like this:

if (lastCell != null) {
    // here lastCell is `cell`, not `cell?`
}

or:

if (lastCell == null || prevCell == null) {
    return;
}
// both lastCell and prevCell are `cell`

or:

var x: int? = ...;
if (x == null) {
    x = random();
}
// here x is `int`

or:

while (lastCell != null) {
    lastCell = lastCell.beginParse().loadMaybeRef();
}
// here lastCell is 100% null

This behavior is known as smart casts, a feature found in TypeScript and Kotlin. Implementing it in a stack-based language like ours was challenging, requiring deep Control Flow Graph (CFG) integration.

Smart casts make working with nullable types more natural. Once a variable is checked, the compiler automatically understands that it is non-null, allowing operations without redundant type assertions.

Smart casts work for local variables and tensor/tuple indices. In the future, struct fields will also support smart casts.

var t: (int?, (int?, int?)) = ...;
if (t.1.0 == null) {
    t.1.0 = 0;
}
if (t.0 == null) {
} else {
    return t.0 + t.1.0;
}

However, smart casts don't work for global variables. We do not encourage reading a global variable multiple times (it costs gas). Instead, assign it to a local first:

global gLastCell: cell?;

if (gLastCell != null) {
    // gLastCell still `cell?`
}

var last = gLastCell;
if (c != null) {
    // c is `cell`, it's local
}

Smart casts also work for initial values. Even if a variable declared as int? but initialized with a number, it's a safe non-null integer:

var idx: int? = -1;
// idx is `int`

When a variable is 100% null, its type is null, meaning it can be safely passed to any nullable type:

fun takeOptionalSlice(s: slice?) {}

var i: int? = ...;
if (i == null) {
    takeOptionalSlice(i);    // ok: passing `null` to `slice|null`
}

Indexing var.0 is also not allowed pre-checking:

// t: (int, int)?
t.0                // error
t!.0               // ok
if (t.0 != null) {
    t.0            // ok
}

Why is implementing smart casts challenging?

Smart casts require deep integration into the Control Flow Graph (CFG). The compiler must correctly track variable states across branches, loops, assignments, and conditions. Some examples of tricky cases:

x = x != null ? x : 5;
// x is `int` now
var ok = x != null && y != null && /* here x and y are `int` */ x > y;
if (random() ? x == null : y == null && x == null) {
    // x is 100% null
}
var c = s.loadRef();
var x = c != null ? 10 : null;
// x is `int` (not `int?`) because `c` is always not null
// warning "condition is always false" to be printed

Another complexity arises because variables can be modified at any time. Assignments inside expressions, function arguments, and conditions must be tracked correctly:

beginCell().storeInt(x = 5, 32).storeInt(/* x is `int` now */x, 32);
if (x != null) {
    // x is `int`
    sum = x + f(x = null);
    // x is `null`
}
if ((x1 = random()) < (x2 = random())) { return; }
// x1 and x2 are `int`
fun f(mutate x: int?) { ... }

if (x != null) {
    // x is `int`
    f(mutate x);
    // x is `int?`
}
var (x: int?, y: int?, z: int?) = ...;
((x = 1, 0).0, (y = 2, 1).0) = (3, z = 4);
// all are `int`

Moreover, since indices (t.0, t.1.2, etc.) are tracked, they must be reset when their parent tensor/tuple (t or t.1) is reassigned or mutated.

Once structures are implemented, smart casts for objects and fields will work automatically, just like tensors:

struct Storage {
    lastCell: cell?;
    ...
}

var s: Storage? = ...;
if (s == null) {
    s = loadStorage();
}
// s is `Storage`
if (s.lastCell != null) {
    s.lastCell    // `cell`
}

All in all, implementing smart casts in a stack-based, low-level environment was far from trivial. It required:

  • precise variable state tracking across conditions, loops, and assignments
  • ensuring correctness when nullable values are reassigned
  • optimizations to keep smart cast logic efficient at compile-time

Now, null safety is smooth, intuitive, and enforced at compile time — no runtime cost, no extra gas, just safer code.

Operator ! (non-null assertion)

The ! operator works like TypeScript's ! and Kotlin's !!, but with an important distinction:

  • no runtime check is emitted (unlike Kotlin)
  • it is purely a compile-time assertion that tells the compiler, "I am absolutely certain this value is not null"
fun doSmth(c: cell);

fun analyzeStorage(nCells: int, lastCell: cell?) {
    if (nCells) {           // then lastCell 100% not null
        doSmth(lastCell!);  // use ! for this fact
    }
}

Without !, the compiler would complain that lastCell is cell?, even though external conditions guarantee it is non-null.

Of course, thanks to smart casts, this would also work:

if (nCells && lastCell != null) {
    doSmth(lastCell);
}

However, this relies on a runtime check and consumes gas.
The ! operator is useful when you have guarantees outside of the code itself.

Why use ! in practice?

Basically, when you call a function returning T?, but you are why ever sure that it's not null:

// this key 100% exists, so force `cell` instead of `cell?`
val mainValidators = getBlockchainConfigParam(16)!;

Low-level functions working with dictionaries from @stdlib/tvm-dicts cannot be fully expressed using the type system. For example, iDictGet() returns either (slice, true) or (null, false), which makes its null safety dependent on an additional boolean flag.

@pure
fun iDictGet(self: cell?, keyLen: int, key: int): (slice?, bool)
    asm(key self keyLen) "DICTIGET" "NULLSWAPIFNOT";

var (cs, exists) = dict.iDictGet(...);
// if exists is true, cs is guaranteed to be non-null
if (exists) {
    cs!.loadInt(32);
}

// an alternative (but requires stack manipulations)
if (cs != null)

Because dictionary APIs operate at a very low level, they cannot be expressed using standard null safety rules. In the future, Tolk will introduce a high-level map<K, V> that eliminates the need for !. Until then, working with dictionaries will often require !.

Also, unlike locals, global variables cannot be smart-cast. The ! operator is the only way to drop nullability from globals:

global gLastCell: cell?;

doSmth(gLastCell!);

So, the ! operator is powerful but should be used carefully. It's a tool for cases where you know something the compiler does not — whether it's guarantees from external logic or low-level TVM operations.

Code after throw is treated unreachable

The statement throw X is a sugar for __throw(X), a regular built-in function. Prior to this release, it was not handled specially:

fun f(x: int) {
    if (x > 0) {
        return x;
    }
    throw 123;
    // earlier, `return` statement needed here
    // now, `throw` interrupts control flow
}

Another example:

if (...) {
    return 0;
} else {
    throw 123;
}
// unreachable

It works, because the built-in function returns never:

fun __throw(errCode: int): never
    builtin;

The never type

The never type represents code paths that are unreachable. It allows functions that always throw or never return to be safely typed:

fun alwaysThrows(): never {
    throw 123;
}

fun f(x: int) {
    if (x > 0) {
        return x;
    }
    alwaysThrows();
    // no `return` statement needed
}

Functions with never return type must not return normally. This is not commonly used in practice, but it makes code more explicit and behaves exactly as expected.

Implicit never in unreachable conditions

The never type also appears implicitly when a condition is impossible:

var v = 0;
// prints a warning, `int` can't be `null`
if (v == null) {
    // here, v has the type `never`
    v + 10;   // error, can not apply `+` to `never` and `int`
}
// v is `int` again

If you encounter never in a compilation error, it usually means that a preceding condition is invalid or contains a warning. Checking for compiler warnings will often reveal the root cause.

Implementation details: non-trivial nullable types

As told above, atomics like int? / cell? / etc. are still atomics: at runtime, they hold either TVM NULL or a value. Checking v == null for them is expressed as Fift ISNULL.

But what about nullable tensors? (int, int)?, (int?, int?)?, or even ()?? How should they be stored on a stack? How should null equality work?

The rule is the following: if T can not hold TVM NULL instead of itself, a special "value presence" stack slot is implicitly added. It holds 0 if value is null, and not 0 (currently, -1) if not null:

// t: (int, int)?
t = (1, 2);    // 1 2 -1
t = (3, 4);    // 3 4 -1
t = null;      // null null 0

// t: ()?
t = ();         // -1
t = null;       // 0

Checking v == null for such types is expressed as 0 EQINT for the last slot.

Smart casting (and ! operator) works by just cutting off the last slot:

if (t != null) {    // 1 2 -1, check "0 NEQINT"
    var t2 = t;     // 1 2
}
t!                  // 1 2

It also means, that null can be actually N nulls on a stack. Same, (1,2) passed to nullable implicitly adds "-1":

fun takeNullableTensor(t: (int, int)?);

takeNullableTensor(null);   // null null 0
takeNullableTensor((1, 2)); // 1 2 -1

var t1 = (1, null);               // 1 null
var t2: (int, (int,int)?) = t1;   // 1 null null 0

This process of (appending or dropping slots) is called "transition to target (runtime) type". In the example above, original type is null / (int, int), target_type is (int, int)?.

By the way, not every nullable tensor requires adding a special slot. Some tricky examples, when it's not added:

var t: (int, ())? = (5, ());    // 5
t == null                       // false
t = null;                       // null
t == null                       // true

But :) If we change it a little bit, then we need an extra slot again:

var t: (int?, ())? = (5, ());   // 5 -1
t.0 = null;                     // null -1
t == null                       // false
t = null;                       // null 0
t == null                       // true

When structures are implemented, they will work like tensors, and all the algorithms above will smoomthly apply to them:

struct Point {
    x: int;
    y: int;
}

fun getPairOrNull(): Point?;    // returns 3 slots

struct UserId {
    value: int;
}

fun getIdOrNull(): UserId?;     // returns 1 slot, = `int?`

struct OptionalId {
    value: int?;
}

fun getIdOrNull(): OptionalId?; // returns 2 slots
// v: OptionalId?
// either v == null
// or v.value == null

Implementation details: smart casts and control flow graph

The file smart-casts-cfg.cpp represents internals of AST-level control flow and data flow analysis.

Data flow is mostly used for smart casts and is calculated AT THE TIME of type inferring. Not before, not after, but simultaneously with type inferring, because any local variable can be smart cast, which affects other expressions/variables types, generics instantiation, return auto-infer, etc.

Control flow is represented NOT as a "graph with edges". Instead, it's a "structured DFS" for the AST:

  1. at every point of inferring, we have "current flow facts" (FlowContext)
  2. when we see an if (...), we create two derived contexts (by cloning current)
  3. after if, finalize them at the end and unify
  4. if we detect unreachable code, we mark that path's context as "unreachable"

In other words, we get the effect of a CFG but in a more direct approach. That's enough for AST-level data-flow.

FlowContext contains "data-flow facts that are definitely known": variables types (original or refined), sign state (definitely positive, definitely zero, etc.), boolean state (definitely true, definitely false). Each local variable is contained there, and possibly sub-fields of tensors/objects if definitely known:

// current facts: x is `int?`, t is `(int, int)`
if (x != null && t.0 > 0)
    // current facts: x is `int`, t is `(int, int)`, t.0 is positive
else
    // current facts: x is `null`, t is `(int, int)`, t.0 is not positive

When branches rejoin, facts are merged back (int+null = int? and so on, here they would be equal to before if).

Another example:

// current facts: x is int?
if (x == null) {
    // current facts: x is null
    x = 1;
    // current facts: x is int
}   // else branch is empty, its facts are: x is int
// current facts (after rejoin): x is int

Every expression analysis result (performed along with type inferring) returns ExprFlow:

  1. out_flow: facts after evaluating the whole expression, no matter how it evaluates (true or false)
  2. true_flow: the environment if expression is definitely true
  3. false_flow: the environment if expression is definitely false

An important highlight about internal structure of tensors / tuples / objects and t.1 is sink expressions. When a tensor/object is assigned, its fields are NOT tracked individually.

For better understanding, I'll give some examples in TypeScript (having the same behavior):

// TypeScript
interface User { id: number | string, ... }

var u: User = { id: 123, ... }
u.id    // it's number|string, not number
u = { id: 'asdf', ... }
u.id    // it's number|string, not string
if (typeof u.id === 'string') {
    // here `u.id` is string (smart cast)
}
u.id = 123;
u.id    // now it's number (smart cast) (until `u.id` or `u` are reassigned)
        // but `u` still has type `{ id: number | string, ... }`, 
        // not `{ id: number, ... }`; only `u.id` is refined

The same example, but with a nullable tensor in Tolk:

var t: (int?, ...) = (123, ...)
t.0     // it's int?, not int
t = (null, ...)
t.0     // it's int?, not null
if (t.0 == null) {
    // here `t.0` is null (smart cast)
}
t.0 = 123;
t.0     // now it's int (smart cast) (until `t.0` or `t` are reassigned)
        // but `t` still has type `(int?, ...)`, 
        // not `(int, ...)`; only `t.0` is refined

In the future, not only smart casts, but other data-flow analysis can be implemented.

  1. detect signs: if (x > 0) { ... if (x < 0) to warn always false
  2. detect always true/false: if (x) { return; } ... if (!x) to warn always true

These potential improvements are SignState and BoolState. Now they are NOT IMPLEMENTED, though declared. Their purpose is to show, that data flow is not only about smart casts, but eventually for other facts also (though it's not obvious whether they should be analyzed at AST level or at IR level, like constants now).

Related pull requests

In FunC (and in Tolk before), the assignment
> lhs = rhs
evaluation order (at IR level) was "rhs first, lhs second".
In practice, this did not matter, because lhs could only
be a primitive:
> (v1, v2) = getValue()
Left side of assignment actually has no "evaluation".
Since Tolk implemented indexed access, there could be
> getTensor().0 = getValue()
or (in the future)
> getObject().field = getValue()
where evaluation order becomes significant.

Now evaluation order will be to "lhs first, rhs second"
(more expected from user's point of view), which will become
significant when building control flow graph.
This commit introduces nullable types `T?` that are
distinct from non-nullable `T`.
Example: `int?` (int or null) and `int` are different now.
Previously, `null` could be assigned to any primitive type.
Now, it can be assigned only to `T?`.

A non-null assertion operator `!` was also introduced,
similar to `!` in TypeScript and `!!` in Kotlin.

If `int?` still occupies 1 stack slot, `(int,int)?` and
other nullable tensors occupy N+1 slots, the last for
"null precedence". `v == null` actually compares that slot.
Assigning `(int,int)` to `(int,int)?` implicitly creates
a null presence slot. Assigning `null` to `(int,int)?` widens
this null value to 3 slots. This is called "type transitioning".

All stdlib functions prototypes have been updated to reflect
whether they return/accept a nullable or a strict value.

This commit also contains refactoring from `const FunctionData*`
to `FunctionPtr` and similar.
With the introduction of nullable types, we want the
compiler to be smart in cases like
> if (x == null) return;
> // x is int now
or
> if (x == null) x = 0;
> // x is int now

These are called smart casts: when the type of variable
at particular usage might differ from its declaration.

Implementing smart casts is very challenging. They are based
on building control-flow graph and handling every AST vertex
with care. Actually, I represent cfg not a as a "graph with
edges". Instead, it's a "structured DFS" for the AST:
1) at every point of inferring, we have "current flow facts"
2) when we see an `if (...)`, we create two derived contexts
3) after `if`, finalize them at the end and unify
4) if we detect unreachable code, we mark that context
In other words, we get the effect of a CFG but in a more direct
approach. That's enough for AST-level data-flow.

Smart casts work for local variables and tensor/tuple indices.
Compilation errors have been reworked and now are more friendly.
There are also compilation warnings for always true/false
conditions inside if, assert, etc.
In FunC (and in Tolk before) throwing an exception is just
calling a built-in function:
> throw 123; // actually, __throw(123)
Since it's a regular function, the compiler was not aware
that execution will stop, and all following code is unreachable.
For instance, `throw` in the end on function needed to be
followed by `return` statement.

Now, `throw` interrupts control flow, all statements after
it are considered unreachable. At IR level, code Ops are
also not produced.

This works because a built-in __throw() now has `never` type.
It can also be applied to custom functions:
> fun alwaysThrow(): never { throw 123; }
The code after alwaysThrow() call will also be unreachable.
@tolk-vm tolk-vm changed the title Tolk v0.9: nullable types T?, null safefy, control flow, smart casts Tolk v0.9: nullable types T?, null safety, control flow, smart casts Mar 5, 2025
@EmelyanenkoK EmelyanenkoK merged commit 6eeae5d into testnet Mar 5, 2025
17 checks passed
@tolk-vm tolk-vm added the Tolk Related to Tolk Language / compiler / tooling label Mar 5, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Tolk Related to Tolk Language / compiler / tooling
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants