DEV Community

Cover image for Negate Operator (`~`) in TL-B, Explained
Salikh Osmanov
Salikh Osmanov

Posted on

Negate Operator (`~`) in TL-B, Explained

Introduction

The negate operator (~, often called the “tilde”) is one of the trickiest parts of TL-B. It shows up all over TON schemas, and understanding it is essential for reading schemas, implementing serializers/deserializers, and building tooling.

Before diving in, it’s worth skimming:

And browsing real .tlb schemas in TON repositories:

Note: Examples below use TypeScript-style pseudo-objects for clarity.


Where ~ can appear—and what it does

~ appears in two places, with different roles:

  1. Inside curly braces { … } on the left (fields) side of a combinator This is a constraint/equation context. You can:
    • Define the value of an implicit field (not serialized) by equation.
    • Constrain the value of an explicit field (serialized) by equation.

⚠️ { ~x = expr } does not “override” a field value.

It requires that the serialized value equals expr. If it doesn’t, validation fails.

  1. On the right (type name) side after = … This is a return context. ~expr makes the constructor return an integer value that outer types can use as a parameter. Returned values don’t get serialized; they’re outputs from a type.

Using ~ in field/constraint equations

Constraint on an explicit field

_ val:(## 8) { ~val = 2 } = IntVal;
Enter fullscreen mode Exit fullscreen mode
  • val is serialized as 8 bits.
  • The constraint requires val == 2.

This is equivalent to:

_ val:(## 8) { val = 2 } = IntVal;
Enter fullscreen mode Exit fullscreen mode

For explicit fields, you can use constraints with or without ~. Both mean the same thing: the serialized value must satisfy the condition.


Defining an implicit field

Here’s a valid case with constant addition:

_ {limit:#} size:(## 8) { ~limit = size + 1 } = Sized;
Enter fullscreen mode Exit fullscreen mode
  • limit is implicit.
  • It’s defined as one more than the serialized size.

Another valid case with constant multiplication:

_ {bits:#} bytes:(## 8) { ~bits = bytes * 8 } = ByteSized;
Enter fullscreen mode Exit fullscreen mode
  • bits is implicit.
  • It’s always 8 × bytes.

And here’s the real hm_edge definition from hashmap.tlb showing variable + variable:

hm_edge#_ {n:#} {X:Type} {l:#} {m:#} 
  label:(HmLabel ~l n) { n = (~m) + l } 
  node:(HashmapNode m X) 
= Hashmap n X;
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • n, l, m are implicit parameters.
  • The label encodes a prefix of length l.
  • { n = (~m) + l } is a constraint: the total key length n is equal to the remaining part size ~m plus the prefix length l.
  • The node encodes the subtree for the remaining m bits.
  • The type returns Hashmap n X.

This shows that variable + variable is legal in TL-B equations.


Allowed operators in these equations

  • Addition +
    • constant + variable ✅
    • variable + constant ✅
    • variable + variable ✅ (as in n = (~m) + l)
  • Multiplication *
    • constant × variable ✅
    • variable × variable ❌ (not supported)
  • Equality = (to form the equation)

⚠️ Not allowed: subtraction (-), division (/), comparisons (<, <=, etc.)

Valid patterns

{ ~a = 2 }
{ ~a = b }
{ ~a = b + 1 }
{ ~a + 1 = b }     // equivalent to a = b - 1
{ ~a = 2 * b }
{ ~a * 2 = b }     // equivalent to a = b / 2
{ n = (~m) + l }   // variable + variable (as in hashmap.tlb)
Enter fullscreen mode Exit fullscreen mode

Using with Type Parameters

First, let’s recall how parameters work for types.

How do variables work?

If we declare the following:

_ {size:#} a:(## size) = A size;
Enter fullscreen mode Exit fullscreen mode

To serialize, we must provide the value of the size parameter. The object we want to serialize might look like this:

const A = {
    size: 8,
    a: 24
};
Enter fullscreen mode Exit fullscreen mode

After serialization we get the following bitstring:

00011000
Enter fullscreen mode Exit fullscreen mode

This is the binary representation of the value 24 (a field), written using 8 bits (as specified by the size parameter).

As we can see, the value of a parameter must be known before serializing an object.

The same is true when we want to deserialize.

Suppose we have the following bitstring:

0010000101
Enter fullscreen mode Exit fullscreen mode

We must know how many bits to take in order to determine the value of the a field.

  • If the size parameter’s value is 3, then we take the first 3 bits (001) → a = 1.
  • If size is 4, then we take the first 4 bits (0010) → a = 2.

So, parameters of a type must be known for both serialization and deserialization.


Returning a value instead of passing a parameter

If we set a parameter with the negate operator (~) in front of it, then we don’t have to provide the value explicitly. Instead, the type will return a value that can be used as a parameter.

Example:

_ val:(## 8) = IntVal ~val;
Enter fullscreen mode Exit fullscreen mode

Here, the constructor “returns” the value of the val field.


Example of usage

Suppose we have the following type declaration representing a cart item:

_ id:# price:(## 16) quantity:(## 8) = Item;
Enter fullscreen mode Exit fullscreen mode

This is simple — we just have 3 properties. An object might look like:

const item1 = {
    id: 17,
    quantity: 2,
    price: 120
};
Enter fullscreen mode Exit fullscreen mode

Now let’s define a cart type that can contain n items:

_ {n:#} items:(n * Item) = Cart n;
Enter fullscreen mode Exit fullscreen mode

Here, n is implicit and sets the number of items. We pass the value through a parameter.

But suppose we want to serialize the number of items instead of passing n. That means we should use an explicit field rather than an implicit one:

_ n:# items:(n * Item) = Cart;
Enter fullscreen mode Exit fullscreen mode

We can make the cart more advanced by introducing a separate type for metadata, e.g., storing the number of items (and potentially discounts, etc.):

_ items_count:# = CartMetaData;
Enter fullscreen mode Exit fullscreen mode

But to connect it, we must return the value of items_count so it can be used in the Cart type:

_ id:# price:(## 16) quantity:(## 8) = Item;
_ items_count:# = CartMetaData ~items_count;
_ metadata:CartMetaData ~n items:(n * Item) = Cart;
Enter fullscreen mode Exit fullscreen mode

Notice:

  • The n field in Cart is not implicit here, because its value is serialized in the bitstring.
  • CartMetaData returns items_count, which is then passed as n to the Cart.

An example object:

const my_cart = {
    metadata: {
        items_count: 2
    },
    items: [
        { id: 172, price: 100, quantity: 1 },
        { id: 23, price: 50, quantity: 4 }
    ]
};
Enter fullscreen mode Exit fullscreen mode

⚠️ Reminder: all bits are stored in a Cell, and the size must not exceed 1023 bits. Larger objects must be split across cells.


Returning an expression

You can also return expressions, for example:

_ a:# b:# = TwoNat ~(a + b);
_ {sum:#} numbers:(TwoNat ~sum) = Example;
Enter fullscreen mode Exit fullscreen mode
  • TwoNat encapsulates two numbers.
  • It also returns their sum, which can be used in another type.

Returning a constant

You can return constants as well:

_ a:# = A ~4;
_ {x:#} b:(A ~x) = B;
Enter fullscreen mode Exit fullscreen mode

Here, A always returns 4. Therefore, the implicit variable x in B will always be 4.


Optional use

Unlike type parameters, returned values are optional. You may use them or simply ignore them.

_ val:# = A ~val;
_ a:A b:# = A_and_B;
Enter fullscreen mode Exit fullscreen mode

Although A returns the value of its val field, A_and_B does not use it.

Even when parameters are present, returned values may still be omitted:

_ {length:#} val:(## length) = A ~val length;
_ a:(A 8) b:# = A_and_B;
Enter fullscreen mode Exit fullscreen mode

Here:

  • 8 is the parameter passed to A as its length.
  • A also returns val, but A_and_B doesn’t use it.

Recursive expression

Now let’s look at the case when the negate operator is used to recursively serialize an object or deserialize a bitstring.

The most common example is the unary type:

unary_zero$0 = Unary ~0;
unary_succ$1 {n:#} x:(Unary ~n) = Unary ~(n + 1);
Enter fullscreen mode Exit fullscreen mode

This effectively means the following expansions:

unary_zero$0                       = Unary ~0;
unary_succ$1 {n:#} x:(Unary ~0)    = Unary ~1;
unary_succ$1 {n:#} x:(Unary ~1)    = Unary ~2;
unary_succ$1 {n:#} x:(Unary ~2)    = Unary ~3;
…
unary_succ$1 {n:#} x:(Unary ~n)    = Unary ~(n + 1);
Enter fullscreen mode Exit fullscreen mode

In other words: each time we parse a unary_succ, we return one more than what we get after deserializing the next bit of the bitstring.


Using Unary in another type

Suppose we use Unary in the following type:

_ {l:#} len:(Unary ~l) = Example;
Enter fullscreen mode Exit fullscreen mode

Let’s try to deserialize the bitstring 110 into an object of type Example.

We’ll build it step by step as a TypeScript object.


Step 1 — initialize

Example has no tag prefix, so we can start with an empty object:

const example = {
    len: { /* … */ },
    get l() {
        return this.len.n;
    }
};
Enter fullscreen mode Exit fullscreen mode

Here:

  • len is of type Unary.
  • l is an implicit field, equal to the value returned by len.

Step 2 — first bit

The first bit of the bitstring is 1, so we use unary_succ. That gives us:

const example = {
    len: {
        x: { /* … */ },
        get n() {
            return this.x.n + 1;
        }
    },
    get l() {
        return this.len.n;
    }
};
Enter fullscreen mode Exit fullscreen mode

Notice:

  • n is returned.
  • Its value is this.x.n + 1.

It would be more intuitive to write the schema as:

unary_succ$1 {n:#} x:(Unary ~(n-1)) = Unary ~n;
Enter fullscreen mode Exit fullscreen mode

But two problems arise:

  1. We can’t use calculations like (n-1) in field declarations.
  2. The - operator is not supported in TL-B.

Step 3 — recurse

The remaining bitstring is 10.

The next bit is 1, so again we apply unary_succ:

const example = {
    len: {
        x: {
            x: { /* ... */ },
            get n() {
                return this.x.n + 1;
            }
        },
        get n() {
            return this.x.n + 1;
        }
    },
    get l() {
        return this.len.n;
    }
};
Enter fullscreen mode Exit fullscreen mode

Step 4 — base case

The last bit is 0, which means we use unary_zero. That returns 0.

Final object:

const example = {
    len: {
        x: {
            x: { 
                n: 0
            },
            get n() {
                return this.x.n + 1;
            }
        },
        get n() {
            return this.x.n + 1;
        }
    },
    get l() {
        return this.len.n;
    }
};
Enter fullscreen mode Exit fullscreen mode

Here n = 0 because unary_zero returns 0.


Result

If we run:

console.log(example.l);
Enter fullscreen mode Exit fullscreen mode

We get 2, which means len:(Unary ~2).

Indeed, the bitstring 110 contains two 1 bits before the terminating 0.


Common pitfalls

  • { ~val = 2 } vs { val = 2 } → ✅ both work on explicit fields.
  • { ~val = 2 } checks, it doesn’t assign.
  • Addition: variable + variable ✅ (e.g. n = (~m) + l in hashmap.tlb).
  • Multiplication: only constant × variable ✅, variable × variable ❌.
  • No subtraction, division, or comparisons.
  • Returned values (~val) are optional.

Conclusion

The negate operator ~ has two roles:

  • In { … }, it defines implicit fields or enforces constraints.
  • On the right side of a type, it returns integers for outer contexts.

For explicit fields, constraints can be written with or without ~ — both are valid and equivalent.

For implicit fields, you must use ~ to define their values.

And as the hm_edge constructor in hashmap.tlb shows, variable + variable is fully supported in TL-B equations — while variable × variable remains unsupported.

Once you know these rules (and the limits of allowed operators), TL-B schemas with ~ become much clearer to read and implement.

Top comments (0)