Skip to content

Types

Every expression in Katnip has a type, and the compiler checks them before emitting a single block. Scratch itself has no types at all — it coerces everything at runtime — so this whole layer exists purely at compile time and costs nothing in the output.

Type Holds Scratch equivalent
num numbers, integer or decimal a value in a number slot
str text a value in a text slot
bool true / false a hexagonal boolean slot
void nothing; a proc’s “returns no value” a stack block
any anything; opts out of checking
temp count: num = 3;
temp name: str = "Katnip";
temp ready: bool = true;

true and false are declared in the prelude, not keywords. They exist because Scratch has no boolean literal: true is "true" == "true" and false is "false" == "".

public scores: list<num> = [3, 1, 4, 1, 5];
public stock: dict<str, num> = {"apple": 2, "banana": 5};

list<T> is a Scratch list. dict<K, V> is a Katnip invention backed by two Scratch lists — stock_keys and stock_vals — kept in step.

Full detail in Lists and dicts.

A fixed-length, positionally-typed group:

temp pair: (num, num) = ...;
temp columns: (list<str>, list<num>) = zip(names, powers);

Tuple types are checked structurally — (num, num) and (num, num) are the same type no matter where they were written. They are used for multi-value returns and for the (key, value) binding in a dict for loop.

A value that may be one of several types, written with |:

proc goTo(position: Target | str) -> void {}

Assignability works in both directions: a str fits a Target | str parameter, and a Target | str fits a str parameter only if every member is assignable.

The standard library uses unions to let you pass either a named target or a raw string:

motion.goTo("_random_"); # str arm
sensing.touching("_edge_"); # str arm
clone.create("_myself_"); # str arm

Nominal, not structural — two enums that share a member name never compare equal.

enum Fruit { apple, banana, cherry }
enum Team { red = "R", blue = "B" }

A literal that matches one of an enum’s member values is assignable to that enum, so temp t: Team = "R"; and temp t: Team = Team.red; mean the same thing. A computed value of the backing type is not — see Enums and structs.

Nominal record types with named fields:

struct Point { x: num, y: num }

An annotation is optional whenever there is an initializer to infer from:

temp count = 3; # num
temp name = "Katnip"; # str
temp scores = [3, 1, 4]; # list<num>

Annotate when you want the compiler to hold you to something:

temp scores: list<num> = []; # otherwise the element type is open

Procedure parameters and return types are always annotated. There is no inference across a procedure boundary.

The stdlib declares typevars T, K, and V, which bind from the receiver and the arguments at each call:

temp fighters: list<str> = ["Ember", "Splash"];
fighters.contains("Ember"); # T binds to str -> bool
fighters.length(); # T binds to str -> num
zip(fighters, powers); # -> (list<str>, list<num>)
enumerate(fighters); # -> (list<num>, list<str>)

You cannot declare your own generic procedures — typevars are a stdlib facility.

Katnip does not silently convert. num into a str slot is an error, and the casts that would fix it are declared but not yet lowered:

temp s: str = Str(42); # type-checks, then fails at codegen

Until they land, use an interpolated string, which lowers to join and produces a str:

temp s: str = f"{42}"; # works today