​

Toit for C and C++ programmers

This introduction covers Toit from a C/C++ starting point. Familiar operations get a short explanation; calls, blocks, managed objects, and cooperative tasks get more space. The examples run on your computer with the Toit SDK.

Each example is a complete program unless a filename is given. Replace the previous example when running the next one. For a quick syntax lookup, use the comparison cheat sheet.

A program starts at main

Toit uses indentation instead of braces. Functions can be defined directly in a file, and statements do not end with semicolons. Here is a complete program:

add a b:
  return a + b

main:
  count := 3
  count++
  print (add count 2)  // Prints 6.

Use two spaces per indentation level. Comments use // or /* ... */. Variables and functions use kebab-case, classes use UpperCamelCase, and constants use UPPER-KEBAB-CASE. A hyphen can belong to a name: item-count is one variable, while item - count is subtraction.

Variables and scope

:= declares a mutable binding; = assigns a new value. ::= declares a final binding, which cannot be reassigned. Normally use := inside functions. Prefer final fields and globals when their bindings will not change.

Finality applies to the binding, not to the referenced object's contents. It is not C++ const access to an object:

NAMES ::= ["Ada"]

main:
  NAMES.add "Grace"
  print NAMES.size  // Prints 2.

Local names belong to their enclosing scope. Top-level variables are globals; their initializers run on first access. Objects are managed references. Passing an object to a function does not copy it, and assignment does not invoke a user-defined copy constructor.

Calling functions and methods

Spaces separate call arguments: add 1 2. Parentheses group an expression when its result is needed by another call: print (add 1 2).

Arithmetic binds more tightly than calls. Although add 1 2 * 3 means add 1 (2 * 3), prefer the latter spelling, especially with multiple arguments.

A zero-argument call needs no parentheses. name.trim invokes trim; it does not produce a member-function pointer. Public fields use the same access syntax, so a field can later become a computed getter without changing readers.

Named and default arguments

Both positional and named parameters can have default values. A named parameter uses -- in its definition and calls:

greet name="World" --greeting="Hello" --loud=false:
  text := "$greeting $name"
  print (loud ? "$text!" : text)

main:
  greet
  greet "Ada" --greeting="Hi" --loud
  greet "Grace" --no-loud

This prints Hello World, Hi Ada!, then Hello Grace. A bare boolean flag passes true; its --no- form passes false.

Toit overloads by argument count and names, not by parameter types. Use return to supply a function's result. Type annotations can document and check the arguments and result, as we will see next.

Types and null

Types are optional. Parameters and variables use /Type; return annotations use -> Type. The annotations check actual values at runtime:

add a/int b/int -> int:
  return a + b

main:
  print (add 2 3)
  value/any := "3"
  error := catch: add 2 value
  if error:
    print "Expected integers"

This prints 5, then Expected integers. The any annotation permits any value in value, but add still requires integers. Its second call fails a parameter check; we will cover catch below. Unlike C++, not every type mismatch prevents compilation. Analysis catches some mistakes, while the runtime enforces the annotations.

null denotes absence. Types exclude it by default; string? accepts a string or null. any accepts any value, including null, and none marks a function with no result. A nullable reference does not support pointer arithmetic or manual dereferencing.

Toit permits assigning a nullable type to its non-nullable counterpart statically, with a runtime check on the value. The type Null itself is the exception. See the nullable assignment example.

Use value is Type for a type test and value as Type for a checked cast. A cast does not convert a value; conversions use methods such as to-float or functions such as int.parse.

Numbers, booleans, and operators

Integers are signed 64-bit values; floats are double precision. Integer division truncates toward zero, as in C/C++: -5 / 2 is -2. A float operand preserves the fractional part: 5 / 2.0 is 2.5.

Arithmetic and comparison operators are familiar: +, -, *, /, %, ==, !=, <, <=, >, and >=. Use and, or, and not for logic. Only false and null are falsy; unlike C/C++, zero is truthy.

main:
  print (0 or 10)      // Prints 0.
  print (null or 10)   // Prints 10.
  print (5 / 2)       // Prints 2.
  print (5 / 2.0)     // Prints 2.5.

and and or short-circuit and return an operand, rather than always producing a boolean. An empty list or string is also truthy; check its size or use a collection's is-empty getter when you mean emptiness.

Bitwise operators are &, |, ^, ~, <<, >>, and >>>. The last is unsigned right shift. Hexadecimal and binary literals use 0x and 0b; underscores can group digits, as in 0xffff_ffff.

Strings and formatting

Strings are immutable UTF-8 text. Double quotes create a string; single quotes create an integer code point, so 'A' is 65. Use triple double quotes for multiline strings, \n for a newline, and \$ for a literal dollar sign.

Insert values with $name or $(expression). Formatting puts the specifier next to the value, instead of in a separate printf argument list:

main:
  name := "Ada"
  print "Hello $name"
  print "Name length: $name.size"
  print "Next: $(21 + 1)"
  print "Pi: $(%.2f 3.14159)"

This prints Hello Ada, Name length: 3, Next: 22, and Pi: 3.14. Member access and indexing can be part of interpolation. Use $(name)-sensor when a literal hyphen should follow the name.

String offsets count UTF-8 bytes: "é".size is 2. Indexing at a character boundary returns a code point; indexing at a continuation byte returns null. Use text.do --runes: to iterate over code points. See strings for slicing and formatting details.

Conditions and loops

if, else if, else, while, break, and continue work much as in C/C++, with indentation in place of braces. The conditional expression uses condition ? yes : no. A C-style for loop is available:

main:
  for i := 0; i < 4; i++:
    if i == 1: continue
    print i  // Prints 0, 2, then 3.

  remaining := 3
  while remaining > 0:
    remaining--
  print remaining  // Prints 0.

For a fixed number of repetitions, normally use 3.repeat:. To iterate over a collection, use values.do:. These are ordinary methods taking blocks, which let library code provide control structures.

Blocks provide scoped control flow

A block starts with :. Parameters appear between | characters. The block can read and update locals in the surrounding function:

main:
  total := 0
  3.repeat: |i|
    total += i
  print total  // Prints 3.

A return in a block returns from the function in which the block was written. This is a non-local return, unlike returning from a C++ lambda:

first-positive values:
  values.do: |value|
    if value > 0:
      return value
  return null

main:
  print (first-positive [-1, 0, 4, 8])  // Prints 4.

The return leaves both the block and first-positive. Use continue.do to skip one do iteration, or continue.repeat for repeat. Those exits leave the enclosing function running.

Accepting a block

A block parameter is written in square brackets. Invoke it with .call. The block's last expression supplies its result; return would instead leave the caller's enclosing function:

with-offset offset/int [action]:
  return action.call offset

main:
  base := 10
  result := with-offset 5: |offset|
    base + offset
  print result  // Prints 15.

Lifetime and cost

Block references encode stack-relative positions as small integers. Passing a block pushes a reference; it does not allocate a heap closure to copy captured locals. The language enforces the lifetime that makes this safe: blocks cannot escape into fields, globals, collections, or return values. A lambda cannot capture a block either.

C++ lambdas can also avoid heap allocation, but a lambda capturing references can outlive the referenced locals. C++ does not enforce Toit's block lifetime restriction. Use blocks for scoped callbacks such as iteration and resource management.

Lambdas for stored callbacks

A lambda starts with :: and can outlive the call that created it. Its last expression is its result; it cannot contain an explicit return:

make-adder amount/int -> Lambda:
  return :: |value| value + amount

main:
  add-five := make-adder 5
  print (add-five.call 3)  // Prints 8.

The lambda keeps access to amount. It can be stored in a field or returned, as here. Use the form the API expects: [action] accepts a block; a stored callback uses a lambda. See blocks and lambdas.

Collections and binary data

Lists, maps, sets, and byte arrays have literal syntax:

CollectionLiteralTypical operation
List[1, 2, 3]; empty []values.add 4
Map{"name": "Ada"}; empty {:}labels["name"]
Set{1, 2, 3}; empty {}seen.contains 2
Byte array#[0, 127, 255]; empty #[]bytes[0]

Collections can hold mixed types. List does not take a template argument for its elements; use annotations or checks where element types matter.

Use map to transform, filter to select, and reduce to combine elements. Short one-parameter blocks can use the implicit name it:

main:
  readings := [21, 26, 25]
  warm := readings.filter: it >= 25
  labels := readings.map: "$it C"
  total := readings.reduce --initial=0: |sum reading| sum + reading
  print warm               // Prints [26, 25].
  print (labels.join ", ")  // Prints 21 C, 26 C, 25 C.
  print total              // Prints 72.

A map lookup with brackets throws if the key is absent; labels.get "name" returns null when absent. Use --if-absent to compute a different fallback.

A slice such as values[1..3] includes index 1 and excludes index 3. List slices are views into the original list: writes affect the shared elements, and the slice keeps the entire original list alive. Use .copy for a shallow copy with independent element slots.

Use a ByteArray and encoding APIs for binary data. Do not depend on object layout or C struct packing:

import io

main:
  bytes := ByteArray 4
  io.LITTLE-ENDIAN.put-uint32 bytes 0 0x1234
  print (io.LITTLE-ENDIAN.uint32 bytes 0)  // Prints 4660.

Classes, fields, and methods

Call a class to construct an object, without new. Declare fields in the class; objects cannot gain additional fields dynamically. Methods have fields and other methods in scope, so this. is usually unnecessary:

class Counter:
  value_/int := ?

  constructor .value_=0:

  value -> int: return value_

  increment -> none:
    value_++

main:
  counter := Counter 4
  counter.increment
  print counter.value  // Prints 5.

:= ? requires the constructor to initialize the field. The .value_ parameter stores its argument directly in the field. A field declared as name/string without := is final and must also be initialized by the constructor.

A trailing underscore marks a private member. The public value getter uses the same call syntax as a public field. A setter is written value= new-value: and called with counter.value = new-value.

Inheritance and interfaces

A class can extend one superclass and implement multiple interfaces. Interfaces must be implemented explicitly. Methods do not need a virtual declaration to be overridden:

interface Described:
  description -> string

class Device implements Described:
  name/string

  constructor .name:

  description -> string: return name

class Sensor extends Device:
  constructor name/string:
    super name

  description -> string:
    return "Sensor: $(super)"

main:
  device/Described := Sensor "Kitchen"
  print device.description  // Prints Sensor: Kitchen.

In a constructor, super calls the superclass constructor. In an overriding method, it calls the superclass's method with the same name. This is not the C++ spelling Base::method(...).

Use abstract class and abstract methods for partially implemented classes. Use static for class-level methods and fields. Named constructors, such as Counter.from-reading, give different construction paths descriptive names.

Mixins and factory constructors

Mixins share implementation across classes without adding another superclass. For example, a mixin can provide printing while requiring the class to supply a description:

abstract mixin Printable:
  abstract description -> string

  print-description -> none:
    print description

class Device extends Object with Printable:
  description -> string: return "Device"

main:
  device := Device
  device.print-description  // Prints Device.

A factory constructor returns an object instead of initializing a new instance of its own class. Interfaces and abstract classes can expose factories that choose a concrete implementation:

interface Named:
  constructor name/string:
    return Person name

  name -> string

class Person implements Named:
  name/string

  constructor .name:

main:
  person := Named "Ada"
  print person.name  // Prints Ada.

The return makes this a factory constructor. An interface can only have factories; an abstract class can also have regular constructors for subclasses to call. See the abstract-class factory example and the class reference.

Memory is managed; resources still need cleanup

You do not use malloc, free, new, delete, or pointer arithmetic. Garbage collection reclaims unreachable objects. It does not provide C++ destructor semantics for closing files, sockets, or peripherals at a particular time. Use try: with finally: for explicit cleanup.

throw raises an exception. catch: runs a block and returns the thrown value, or null when nothing was thrown. Exceptions are values; strings are common:

main:
  error := catch:
    try:
      print "Using resource"
      throw "Operation failed"
    finally:
      print "Cleanup runs here"
  if error:
    print "Handled: $error"

This prints the resource message, the cleanup message, then Handled: Operation failed. Replace the cleanup message with the resource's close operation in real code. There is no combined try/catch syntax: nest the cleanup inside catch when both are needed. Throw a truthy value when using if error to detect a failure. See exception handling.

Libraries replace headers

Each .toit file is an importable library; declarations do not need separate headers. Put imports at the top of the file. Core names such as print and List are available automatically:

import math

main:
  print (math.sqrt 9.0)  // Prints 3.0.

import .helpers imports a sibling helpers.toit and exposes its members without a prefix by default. import .helpers as helpers chooses an explicit prefix. SDK and package imports normally use a prefix; show selects names. See the two-file example.

Dependencies on separately distributed libraries are declared in package.yaml; package.lock records resolved versions. Imports are resolved before execution. Use the package quick start to add a dependency.

Tasks cooperate within a program

Each task has a call stack. Tasks share objects and take turns running, switching at yielding operations such as waiting for I/O or sleeping. Ordinary functions can wait without a special declaration or a callback at every call:

report label/string delay/int -> none:
  2.repeat:
    sleep --ms=delay
    print label

main:
  task:: report "Fast" 10
  task:: report "Slow" 25

Each task prints twice. While one is waiting, another can run. The timing is not a real-time guarantee.

Unlike preemptive threads, another task in the same program cannot interrupt a computation that does not yield. A loop that never yields starves other tasks. Conversely, shared state can change across a waiting call, so operations spanning a wait may need synchronization. A helper can yield even though its call looks ordinary. See tasks and synchronization.

Continue with a project

You can use Toit for host scripts, command-line tools, network applications, and device programs. Use the language reference to look up details, the package quick start to add libraries, or Run on your device to work with an ESP32.