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:
-
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 equalsexpr
. If it doesn’t, validation fails.
-
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;
-
val
is serialized as 8 bits. - The constraint requires
val == 2
.
This is equivalent to:
_ val:(## 8) { val = 2 } = IntVal;
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;
-
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;
-
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;
Explanation:
-
n
,l
,m
are implicit parameters. - The
label
encodes a prefix of lengthl
. -
{ n = (~m) + l }
is a constraint: the total key lengthn
is equal to the remaining part size~m
plus the prefix lengthl
. - The
node
encodes the subtree for the remainingm
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)
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;
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
};
After serialization we get the following bitstring:
00011000
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
We must know how many bits to take in order to determine the value of the a
field.
- If the
size
parameter’s value is3
, then we take the first 3 bits (001
) →a = 1
. - If
size
is4
, 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;
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;
This is simple — we just have 3 properties. An object might look like:
const item1 = {
id: 17,
quantity: 2,
price: 120
};
Now let’s define a cart type that can contain n
items:
_ {n:#} items:(n * Item) = Cart n;
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;
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;
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;
Notice:
- The
n
field inCart
is not implicit here, because its value is serialized in the bitstring. -
CartMetaData
returnsitems_count
, which is then passed asn
to theCart
.
An example object:
const my_cart = {
metadata: {
items_count: 2
},
items: [
{ id: 172, price: 100, quantity: 1 },
{ id: 23, price: 50, quantity: 4 }
]
};
⚠️ 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;
-
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;
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;
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;
Here:
-
8
is the parameter passed toA
as itslength
. -
A
also returnsval
, butA_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);
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);
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;
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;
}
};
Here:
-
len
is of typeUnary
. -
l
is an implicit field, equal to the value returned bylen
.
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;
}
};
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;
But two problems arise:
- We can’t use calculations like
(n-1)
in field declarations. - 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;
}
};
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;
}
};
Here n = 0
because unary_zero
returns 0
.
Result
If we run:
console.log(example.l);
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
inhashmap.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)