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
| Type | ABI type | Values |
|---|---|---|
bool | bool | true, false |
uint | uint256 | unsigned 256-bit integers. uint256 is another name for uint |
int | int256 | signed 256-bit integers. int256 is another name for int |
uint8 ... uint248 | same | unsigned integers of that width, in steps of 8 |
int8 ... int248 | same | signed integers of that width, in steps of 8 |
address | address | 20-byte account addresses |
bytes1 ... bytes32 | same | fixed-size byte strings |
bytes | bytes | dynamic byte strings |
string | string | UTF-8 text |
wei | uint256 | an amount of ether |
timestamp | uint256 | a 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.
| Expression | Type |
|---|---|
wei + wei, wei - wei | wei |
wei * 2, wei / 2 (literal) | wei |
timestamp + duration, timestamp + uint | timestamp |
timestamp - timestamp | uint (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))| Conversion | Behavior |
|---|---|
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 width | exact |
uint160(a) for a : address, and address(u) for u : uint160 | exact |
bytes(s) for s : string, and string(b) for b : bytes | exact, with no copy |
bytesN(b) for b : bytes or bytesM | the 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 : bool | 1 for true, 0 for false |
| to or from a nominal type | exact |
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 recordin 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 andxs[i] := vwrites.List.push(xs, v);appendsvto 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'spopdoes. An empty list reverts withPanic(0x31).List.resized(xs, n)is a new list ofxs's firstnelements, padded with zero values;List.resized([], n)is Solidity'snew T[](n).xs.lengthis 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))isuint256[][], 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], andgrid[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.historyA 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)isT[]andlist(T, n)isT[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
varor reading one out of it, the lists in avar's records and tuples included, so two names never share one list: changing avarcan't change anything else. a = bcompares 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 andAbi.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
constandvarlocals, and the parameters and results of internal functions- tuple elements and record fields
caseon any option, including a function's result:case find(id) of { ... }=and<>:storage.admin = None, ora = 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.encodecan't carry an option, even inside a tuple, record, or list. Expose a view that returns a plain value, or aboolfor "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, orbytes. -
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 byteIn 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
| CloseThe 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 toOrderStatusmakes everycasethat 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
caseof 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.
-
SomeandNoneare 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, likestatus = 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 annotationInterface 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;
}
