Skip to content
LogoLogo

Types

Lithe is statically typed. Every local binding names its type, and nothing converts implicitly: not between widths, not between uint and int, and not between a nominal type and its representation.

Primitive types

TypeABI typeValues
boolbooltrue, false
uintuint256unsigned 256-bit integers. uint256 is another name for uint
intint256signed 256-bit integers. int256 is another name for int
uint8 ... uint248sameunsigned integers of that width, in steps of 8
int8 ... int248samesigned integers of that width, in steps of 8
addressaddress20-byte account addresses
bytes1 ... bytes32samefixed-size byte strings
bytesbytesdynamic byte strings
stringstringUTF-8 text
weiuint256an amount of ether
timestampuint256a point in time, in Unix seconds
unit(nothing)the result of a function that returns nothing

Integers

Arithmetic on every integer type is checked. Overflow, underflow, and conversions out of range revert with Panic(0x11), and division by zero reverts with Panic(0x12), as in Solidity. Each fixed width is its own type: a uint8 does not mix with a uint16 or a uint without an explicit conversion.

Where wrapping is the point, as for a price accumulator meant to overflow, name it: Uint.wrappingAdd, Uint.wrappingSub, and Uint.wrappingMul take two values of one unsigned type and keep the result's low bits, as Solidity's unchecked arithmetic does. Int.wrapping_* do the same for signed types. See Built-ins.

storage is record {
  small : uint8;
  total : uint;
}
 
external function add(const a : uint8; const b : uint) is {
  storage.small := a + 1;
  storage.total := uint(a) + b;
}

Semantic types: wei and timestamp

wei and timestamp are uint256 in the ABI, but the type checker keeps them apart from uint and from each other, so a price is never added to a deadline by mistake.

ExpressionType
wei + wei, wei - weiwei
wei * 2, wei / 2 (literal)wei
timestamp + duration, timestamp + uinttimestamp
timestamp - timestampuint (seconds)
Evm.value(), Evm.balance(a)wei
Evm.blockTimestamp()timestamp

Ether literals such as 1eth and 3gwei are wei. Duration literals such as 2days are durations. See Literals and units.

Conversions

Convert with the target type's name, used as a function:

storage is record {
  n : uint;
}
 
external pure function convert(const x : uint8; const raw : bytes20; const amount : uint) : (uint, address, uint16, wei) is
  (uint(x), address(raw), uint16(amount), wei(amount))
ConversionBehavior
to a wider integer (uint(x) for x : uint8)always succeeds
to a narrower integer (uint8(n))checked, Panic(0x11) if out of range
between signed and unsigned (uint(i), int16(n))checked, Panic(0x11) if negative or out of range
address(b) for b : bytes20, and bytes20(a)exact
uint(b) for b : bytes32, uint32(s) for s : bytes4, and back: bytesN and the unsigned integer of the same widthexact
uint160(a) for a : address, and address(u) for u : uint160exact
bytes(s) for s : string, and string(b) for b : bytesexact, with no copy
bytesN(b) for b : bytes or bytesMthe first N bytes of b, zero past its end, as in Solidity
Token(a) for an interface Token, and address(t)exact
wei(n), timestamp(n), uint(w)exact
uint(b) or uint8(b) for b : bool1 for true, 0 for false
to or from a nominal typeexact

To reinterpret the 256 bits between int and uint, as Solidity's uint256(i) and int256(n) do, name the bit-cast: Uint.fromBits(i) and Int.fromBits(n), so that Uint.fromBits(-1) is Uint.max.

Records

A record is a struct: a fixed set of named, typed fields.

import "std/evm";
 
type Position is record {
  owner : address;
  amount : uint;
  openedAt : timestamp;
}
 
storage is record {
  positions : map(uint, Position);
}
 
external function open(const id : uint; const amount : uint) is {
  storage.positions[id] := {
    owner = Evm.sender();
    amount = amount;
    openedAt = Evm.blockTimestamp();
  };
}
 
external function grow(const id : uint; const extra : uint) is {
  storage.positions[id].amount := storage.positions[id].amount + extra;
}

A record literal is { field = value; ... }. It takes its type from where it is used, such as a binding's annotation, a storage field, or a parameter.

Records in the ABI

One rule covers every place a record meets the ABI:

  • A record that is a whole parameter, a whole result, or a variant case's payload is flattened into its fields, like a Solidity getter for a struct: each field is one parameter or result. This holds for a contract's own external functions and for interface methods alike, so a call through an interface uses the signature the other contract dispatches on.
  • A tuple record in those places stays one Solidity tuple, with its fields as named components.
  • A record nested in anything else, such as a list element, a tuple element, another record's field, an event or error field, or a value given to Abi.encode, is always a Solidity tuple.

A tuple record is a record in every other way: fields, literals, storage, and equality. A Solidity struct parameter maps to a tuple record. Uniswap's exactInputSingle((address,address,uint24,address,uint256,uint256,uint160)) takes one:

import "std/evm";
 
type ExactInputSingle is tuple record {
  tokenIn : address;
  tokenOut : address;
  fee : uint24;
  recipient : address;
  amountIn : uint;
  amountOutMinimum : uint;
  sqrtPriceLimitX96 : uint160;
}
 
interface SwapRouter is {
  payable function exactInputSingle(params : ExactInputSingle) : uint;
}
 
storage is record {
  router : SwapRouter;
  weth : address;
  usdc : address;
}
 
external function buy(const amount : uint; const least : uint) is {
  defer storage.router.exactInputSingle({
    tokenIn = storage.weth;
    tokenOut = storage.usdc;
    fee = 3000;
    recipient = Evm.sender();
    amountIn = amount;
    amountOutMinimum = least;
    sqrtPriceLimitX96 = 0;
  });
}

tuple record { ... } is written wherever record { ... } is. Each record type is its own type, so a record and a tuple record with the same fields are different types.

A record's fields can be strings, bytes, and dynamic lists too. An external function that returns such a record returns its fields in order, each dynamic one after an offset, as a Solidity function with several results does:

import "std/evm";
 
type Domain is record {
  fields : bytes1;
  name : string;
  chainId : uint;
  extensions : list(uint);
}
 
storage is record {
  name : string;
}
 
external view function eip712Domain() : Domain is {
  const extensions : list(uint) := [];
} with {
  fields = 0x0f;
  name = storage.name;
  chainId = Evm.chainId();
  extensions = extensions;
}

Tuples

Tuples group values by position. Destructure them in a const binding:

pure function split(const amount : uint) : (uint, uint) is
  (amount / 2, amount - amount / 2)
 
const (half, rest) : (uint, uint) := split(amount);

Tuples nest, can be stored, and pack in storage like a struct. A tuple's elements can be any value: words, strings, bytes, lists, records, and other tuples.

In the ABI, a tuple is always one Solidity tuple, as a struct is: a tuple parameter is f((uint256,string)), and a tuple result is one output. For a struct with named components, use a tuple record. For a function with several results, returns (uint256 a, string b), return a record, whose fields are the results.

Lists

list(T) is a dynamic list and list(T, n) is a fixed list of length n, from 1 to 1024. n is a number or a constant expression: a named constant, M.n() from a module, a cast, or arithmetic over those, such as list(uint, maxOwners() * 2).

import "std/evm";
import "std/list";
 
storage is record {
  history : list(uint);
  top3 : list(address, 3);
}
 
external function push(const value : uint) is {
  List.push(storage.history, value);
  storage.top3[0] := Evm.sender();
}
 
external pure function third() : uint is {
  const primes : list(uint, 4) := [2, 3, 5, 7];
} with primes[2]
  • xs[i] reads and xs[i] := v writes.
  • List.push(xs, v); appends v to a dynamic list, reading and writing its length.
  • List.pop(xs); drops a dynamic list's last element. A stored list clears the element and whatever it holds, a long string's data and an inner list's elements included, as Solidity's pop does. An empty list reverts with Panic(0x31).
  • List.resized(xs, n) is a new list of xs's first n elements, padded with zero values; List.resized([], n) is Solidity's new T[](n).
  • xs.length is the length of a dynamic list.
  • An index past the end, read or written, reverts with Panic(0x32).
  • Elements can be any value: words, strings, bytes, records, tuples, options, and other lists, to any depth. list(list(uint)) is uint256[][], and a list of records with strings is an array of tuples, (string,uint256[])[].
  • Indexes and fields chain: grid[i][j], items[i].tags[j], and grid[i].length. Each index is checked against its own list's length.

In storage, elements narrower than 32 bytes share slots as a Solidity array's do, low bytes first: list(uint8) and list(bool) hold 32 to a slot, list(uint16) 16, list(bytes4) 8, and list(address) one. list(uint8, 3) takes one slot.

A list of records is a Solidity array of structs. In storage each element starts a slot and packs its fields as a struct does, so a record of a uint48 and a uint208 takes one slot. A field of an element reads and writes in place:

import "std/evm";
import "std/list";
 
type Checkpoint is record {
  key : uint48;
  value : uint208;
}
 
storage is record {
  history : list(Checkpoint);
}
 
external function note(const value : uint208) is {
  List.push(storage.history, { key = uint48(Evm.blockNumber()); value = value; });
}
 
external function bump(const i : uint) is {
  storage.history[i].value := storage.history[i].value + 1;
}
 
external view function all() : list(Checkpoint) is
  storage.history

A dynamic list is also a value. It can be a parameter or a result, a const or a var, and it moves to and from storage whole:

import "std/list";
 
storage is record {
  saved : list(uint);
}
 
external function save(const xs : list(uint)) is {
  storage.saved := xs;
}
 
external view function saved() : list(uint) is
  storage.saved
 
external pure function pair(const a : uint; const b : uint) : list(uint) is {
  var out : list(uint) := [a];
  List.push(out, b);
} with out
  • In the ABI, list(T) is T[] and list(T, n) is T[n], and a list of records is an array of tuples, (uint48,uint208)[]. A list argument is checked as Solidity checks one: its offset and length must lie inside the calldata, and each element must hold a value of its type.
  • Assigning a list to storage replaces the stored list, and clears the elements a shorter list no longer has, with whatever they hold.
  • Reading a stored list as a value copies it. So does putting a list into a var or reading one out of it, the lists in a var's records and tuples included, so two names never share one list: changing a var can't change anything else.
  • a = b compares two lists element by element, nested lists and strings included. Records, tuples, fixed lists, options, and variants compare the same way, whatever they hold.
  • Lists can be Abi.encoded and Abi.decoded, sent to and returned from other contracts, and logged in events.

Maps

map(K, V) maps keys of type K to values of type V. It exists only in storage. A key that was never written reads as the zero value, and delete m[k]; clears a key by writing the zero value back.

storage is record {
  balances : map(address, uint);
  allowances : map(address, map(address, uint));
}
 
storage.allowances[owner][spender] := amount;

Keys are single-word values (integers, bool, address, bytesN, wei, timestamp, or nominal types over them) or string and bytes. As in Solidity, a word key's value lives at keccak256(key . slot), and a string or bytes key's at keccak256(bytes . slot), its bytes unpadded. Values are words, maps, lists, records, or options. A record value may hold maps, lists, and strings of its own, as a Solidity struct in a mapping does, and each sits at the slot Solidity gives it:

import "std/evm";
import "std/list";
 
type Role is record {
  members : map(address, bool);
  admin : bytes32;
}
 
storage is record {
  roles : map(bytes32, Role);
  deposits : map(address, list(uint));
}
 
external function grant(const r : bytes32; const who : address) is {
  storage.roles[r].members[who] := true;
}
 
external function deposit(const amount : uint) is {
  List.push(storage.deposits[Evm.sender()], amount);
}
 
external view function deposits(const who : address) : list(uint) is
  storage.deposits[who]

A map's list, or a list inside a map's record, is a list in every way: it can be read and written whole, indexed, and pushed to and popped with List.push and List.pop.

Options

option(T) holds Some(value) or None. Read it with case:

import "std/address";
import "std/evm";
 
storage is record {
  admin : option(address);
}
 
external function appoint() is {
  storage.admin := Some(Evm.sender());
}
 
external view function adminOrZero() : address is
  case storage.admin of {
  | Some(who) -> who
  | None -> Address.zero
  }

T can be any value: a word, string, bytes, a list, a record, a tuple, a variant, or another option. A tuple payload destructures in its arm: | Some((a, b)) -> a + b.

Where options work

An option works anywhere a value does, except the ABI:

  • storage and transient storage fields, map values, and list elements, both stored and local, fixed and dynamic
  • const and var locals, and the parameters and results of internal functions
  • tuple elements and record fields
  • case on any option, including a function's result: case find(id) of { ... }
  • = and <>: storage.admin = None, or a = Some(5)

A map key that was never written, an unwritten list element, and a transient field in a new transaction all read as None. So a map of options tells "never set" apart from "set to zero" at no extra storage cost:

storage is record {
  expiry : map(address, option(uint64));
}
 
external function grant(const who : address; const expiry : uint64) is {
  storage.expiry[who] := Some(expiry);
}
 
external function revoke(const who : address) is {
  storage.expiry[who] := None;
}
 
external view function hasGrant(const who : address) : bool is
  storage.expiry[who] <> None
 
external view function expiryOrZero(const who : address) : uint is
  case storage.expiry[who] of {
  | Some(expiry) -> uint(expiry)
  | None -> 0
  }

Some(x) usually takes its type from where it is used. With nothing to go by, as in case Some(x) of, it takes the type of x. None always needs a known option type.

Where options don't work

  • The ABI. External parameters and results, event fields, error payloads, interface methods, and Abi.encode can't carry an option, even inside a tuple, record, or list. Expose a view that returns a plain value, or a bool for "is set".

    storage is record {
      limit : option(uint);
    }
     
    external function setLimit(const limit : option(uint)) is {
      storage.limit := limit;
    }
    an option cannot be an ABI value
  • Map keys. A key is a single word, a string, or bytes.

  • Transient storage holds only fixed-size values, so not an option of a string, bytes, or a list, or of a value holding one.

Representation

When T is narrower than a word, the option takes one word. That covers bool, address, interfaces, uintN and intN below 256 bits, and bytesN below 32. None is zero. Some(x) stores x bitwise-inverted, or for a signed intN, with bit N−1 flipped, so it's never zero. Some(0) and None stay distinct.

Otherwise the option takes a tag word, 1 for Some and 0 for None, followed by the payload's words. None zeroes every word, and a string, bytes, or list in its payload is empty.

Each word of an option is a whole slot, and a string, bytes, or list word keeps its data where it would as a record field. An option never shares a slot with the fields around it:

storage
  owner: address          slot 0, offset 0, 20 bytes
  admin: option(address)  slot 1
  limit: option(uint256)  slots 2-3
  paused: bool            slot 4, offset 0, 1 byte

In a map or a dynamic list, an option's words take consecutive slots, as a Solidity struct does. map(K, option(uint)) keeps a key's tag at keccak256(key . slot) and its payload in the slot after. Element i of a list(option(uint)) starts at keccak256(slot) + 2i.

Variants

A variant type is a closed set of cases, where each case may carry a payload:

type Action is
  | Deposit external deposit of uint
  | Withdraw external withdraw of uint
  | Close

The first | is optional, so a short variant fits on one line: type phase is Open | Paused | Closed. Written one case per line, the leading | keeps the cases aligned.

A payload can be named, | Deposit of (amount : uint). The name is the ABI parameter's when the case becomes an entrypoint, and nothing else.

A case is written by name, with its payload in parentheses: Close, Deposit(5). A variant is the natural type for stored state, and case has to handle every one of its cases:

import "std/evm";
 
type Fill is record {
  amountOut : uint;
  at : uint64;
}
 
type OrderStatus is
  | New
  | Pending
  | Filled of Fill
  | Unwound
 
type Order is record {
  owner : address;
  status : OrderStatus;
}
 
error AlreadyPlaced of uint;
error NotComplete of uint;
 
storage is record {
  orders : map(uint, Order);
}
 
external function place(const id : uint) raises AlreadyPlaced is {
  assert(storage.orders[id].status = New, AlreadyPlaced(id));
  storage.orders[id].owner := Evm.sender();
  storage.orders[id].status := Pending;
}
 
external function settle(const id : uint; const out : uint) is {
  storage.orders[id].status := Filled({ amountOut = out; at = uint64(Evm.blockTimestamp()); });
}
 
external view function amountOut(const id : uint) : uint raises NotComplete is
  case storage.orders[id].status of {
  | Filled(f) -> f.amountOut
  | New | Pending | Unwound -> fail NotComplete(id)
  }
  • | New | Pending | Unwound -> is one arm for several cases. Listing the cases instead of using a wildcard keeps the match exhaustive: adding a case to OrderStatus makes every case that doesn't handle it a compile error.

  • An arm for several cases binds a payload when each case binds the same names and carries the same payload type: | Queued(reason) | Held(reason) -> reason. Written without names, | Filled | Unwound ->, it ignores the payloads.

  • An arm that fails has no value, so it fits a case of any type.

  • A case name is resolved from the type the context expects. With no expected type, it must belong to only one variant in scope.

  • Some and None are the cases of every option, so no variant can name a case either:

    type ValueType is None | String | Int
     
    storage is record {
      t : ValueType;
    }
    `None` is a case of every option; name this case differently
  • = and <> compare two variants of the same type, payloads included. Comparing with a case that has no payload, like status = New, reads only the tag.

  • A variant value can be a local, a parameter or result of an internal function, a storage field, a map value, a record field, a list element, or an option's payload. It isn't a number: there is no arithmetic, no ordering, no integer literal, and no OrderStatus(n). uint8(m) gives a variant's case index when it has no payloads.

Representation

A variant with no payloads is its case's index, from 0, like a Solidity enum. It takes one byte in storage and packs with the fields around it, and it's a uint8 in the ABI. A decoded value past the last case reverts, as Solidity's decoder does. A variant has at most 256 cases.

A variant with payloads starts a slot, as a record does. The case's index takes its first byte. The cases share their payloads' words by position: the first word of every payload lives in one place, the second in another, and so on. A position whose words are all narrower than a slot is stored as the narrowest integer that holds each of them, and these pack after the index, low bytes first. A position that is a whole word in any case takes a slot. The other cases leave a position zero. The layout above keeps status at keccak256(id . slot) + 1: the index and at share that slot, and amountOut takes the next. A variant whose payloads are all narrow, such as Empty | Stamp of uint64 | Who of address, is one slot.

A payload may hold a string, bytes, or a list. Each such word keeps a slot of its own, shared only with the same type at the same position in another case, and stored as that type is. Writing another case clears it. As a record holding a string can't, such a variant can't be in transient storage.

A variant with payloads has no ABI form, as an option doesn't, so it can't be an external parameter or result, an event field, or an error payload.

A new map entry or an unwritten field reads as the first case, so put the initial state first.

Variants in entrypoint dispatch

An external function whose body is a case on its variant parameter exposes one ABI function per case instead of taking the variant, named by the case's external name, | SetFee external setFee of uint, or by the case itself. A variant whose payload holds a map only shapes entrypoints and has no values. See Entrypoint dispatch.

Nominal types

type name is T declares a new type with T's representation. It does not mix with T, or with any other type declared over T:

type BasisPoints is uint16;
type DiscountRate is uint16;
 
storage is record {
  fee : BasisPoints;
}
 
external function setFee(const fee : BasisPoints) is {
  storage.fee := fee;
}
 
external view function asDiscount() : DiscountRate is
  DiscountRate(uint16(storage.fee))

A nominal type costs nothing at runtime, and in the ABI it appears as its representation. Integer literals take on nominal integer types directly: const fee : BasisPoints := 500;.

A nominal type has its representation's operations, and works in every place the representation does: parameters, locals, storage fields, map keys and values, list elements, events, errors, and ABI parameters and results. For a type over bytes or string, that is .length, = and <>, and every built-in that takes bytes or string: Bytes.concat, Bytes.word, String.concat, Crypto.keccak256 and the other hashes, Base64, Abi.encode, Abi.encodePacked, Abi.encodeWithSignature's signature, Abi.decode's data, the data of Evm.call, the code of create, and precompile inputs. A type over bytes also indexes and slices: d[i] is a bytes1, and d[start:end] is bytes. Whatever a nominal type promises about its bytes, part of it or a join of it with others need not keep that promise, so these results are the representation's type: convert back with doc(...) where the result holds the promise. Only operations carry over, not assignability: a nominal type still passes to your own functions, and compares, only as itself.

import "std/bytes";
 
(* A length-prefixed record: its first byte counts the bytes after it. *)
type Doc is bytes;
 
pure function body(const d : Doc) : bytes is d[1:]
 
pure function wrap(const b : bytes) : Doc is Doc(Bytes.concat(bytes1(uint8(b.length)), b))
 
external pure function size(const d : Doc) : uint8 is if d.length = 0 then 0 else uint8(d[0])
 
external pure function append(const d : Doc; const b : bytes) : Doc is wrap(Bytes.concat(body(d), b))
type BasisPoints is uint16;
type DiscountRate is uint16;
 
storage is record {
  n : uint;
}
 
external function go() is {
  const fee : BasisPoints := 500;
  const discount : DiscountRate := fee;
  skip
}
initializer type does not match its annotation

Interface types

An interface is a type whose values are contract addresses, typed by the methods they support. It is an address in the ABI and in storage.

interface Token is {
  view function balanceOf(owner : address) : uint;
  function transfer(to : address; amount : uint) : bool;
}
 
storage is record {
  token : Token;
}