Skip to content

Operators and expressions

Operator Meaning Scratch block
+ add, or join strings operator_add / operator_join
- subtract operator_subtract
* multiply operator_multiply
/ divide operator_divide
% modulo operator_mod
-x negate operator_subtract from 0
temp total: num = (a + b) * 2 % 10;
Operator Scratch block
== operator_equals
< operator_lt
> operator_gt
<= builtnot (a > b)
>= builtnot (a < b)

Scratch has no <= or >= block. Katnip declares them in the prelude as builds procedures and inlines the body at every use site, so score <= 50 becomes a not wrapping a > — a nested reporter, not a procedure call.

Operator Meaning How
&& and operator_and
|| or operator_or
! not operator_not
^ xor builtnot (a == b)
!& nand builtnot (a && b)
!| nor builtnot (a || b)
!^ xnor builta == b
if ((where >= 1) ^ (total <= 2)) {
looks.say("exactly one");
}

Scratch distinguishes round reporter slots from hexagonal boolean slots, and will not let a round block sit in a hexagonal one. Katnip handles this for you:

  • a round reporter entering a boolean slot is wrapped automatically
  • a literal in a boolean slot becomes a comparison

So you can write the natural thing and the compiler produces a shape Scratch accepts.

+ on strings lowers to operator_join:

temp label: str = "score: " + name;

Prefix a string with f and put expressions in {}:

looks.say(f"{name} scored {score}", 1);

This folds into a right-nested join chain. It is also, today, the most practical way to turn a num into a str, because the Str() cast is not lowered yet:

temp s: str = f"{count}";

An interpolation is a full expression, and it may contain strings — of either quote style, and f-strings of their own. The lexer keeps a stack of string frames, so nesting resumes the outer string correctly:

looks.say(f"{countChar("banana", "a")}"); # ✅
looks.say(f"{countChar('banana', 'a')}"); # ✅
looks.say(f"outer {f"inner {name}"} end"); # ✅

An unterminated string — a missing closing quote, or a { interpolation never closed — is reported as Unterminated string literal at the position the string opened, rather than swallowing the rest of the file.

temp first: str = greeting[1]; # operator_letter_of — 1-based
temp n: num = len(greeting); # operator_length
temp has: bool = greeting.contains("at");

Scratch strings are 1-indexed, and so are Katnip’s.

Two things are resolved before any block is emitted:

Enum members fold to constants:

temp pick: Fruit = Fruit.banana; # the literal "Fruit.banana"
temp side: Team = Team.red; # the literal "R"

Namespace constants fold to their literal value:

temp turns: num = math.pi * 2; # 3.141592653589793 * 2

range() in a for header is folded into the loop counter, with constant folding on literal start / stop / step. It is never built as a list.

Katnip uses a Pratt parser with a binding-power table. Precedence follows the usual arithmetic-then-comparison-then-logic order:

unary - !
* / %
+ -
< > <= >=
==
&& ^ !& !^
|| !|

When in doubt, parenthesise. It costs nothing in the output — the block tree is the same either way.

temp x: num = base ** 2; # DO NOT USE

The ** operator parses and type-checks, but Scratch has no power block and Katnip has no builds procedure for it yet, so the IR silently lowers it to an empty literal. No error, wrong answer. math.pow is a stub with an empty body and has the same problem.

For a whole-number exponent, write the multiplication out or use a loop:

proc pow(base: num, exp: num) -> num {
temp result: num = 1;
for (i, exp) {
result = result * base;
}
return result;
}