A type describes both what a value means and how it is represented. In Livt, choosing a type also establishes hardware properties: the width of a signal, the range of a counter, or the shape of an array. Expressions then describe how those values are calculated, compared, and combined.
This page follows a small packet-header example to explain those choices. Its 16-bit header contains a version, flags, and a payload length. Along the way, we will make widths explicit, extract fields, and check the results in simulation. You can download the complete example project, including its manifest, component, and four runnable tests.
Choose a type
Start with the role of the value. A condition needs a boolean, an external signal needs a hardware logic type, and arithmetic needs a deliberate signed or unsigned interpretation. The following types cover the common cases:
| Type | Representation | Use it for |
|---|---|---|
bool | true or false | Conditions, comparison results, and assertions. |
logic | One hardware logic signal | Single-bit ports and signal states. |
logic[N] | An N-bit logic vector | Control words, packed fields, and signals with an explicit width. |
byte | Unsigned 8-bit value, 0–255 | Packet bytes, encoded text, and raw data. |
int | Signed 32-bit value | Arithmetic that may include negative values. |
uint | Unsigned 32-bit value | Counts and arithmetic with a non-negative interpretation. |
clock, reset | Signals with timing roles | Component execution contexts. |
string | Text | Reports, compile-time text, and data to encode as bytes. |
Use bool to express a decision, such as whether a length is valid. Use logic for a hardware signal that may carry HDL-style states. Alongside zero and one, logic literals support U (uninitialised), X (unknown), Z (high impedance), W (weak unknown), L (weak zero), H (weak one), and - (don’t care). These states are useful in simulation; they are not ordinary numeric values.
let accepted: bool = true
let valid: logic = 0b1
let unknown: logic = 0bX
let flags: logic[4] = 0b0101
let payloadByte: byte = 0x3C
A scalar logic and a one-bit vector logic[1] are different types. Use the scalar for a single signal and the vector when an API expects a vector shape. Similarly, byte communicates unsigned data, while logic[8] communicates a bit pattern. That distinction matters when converting to a signed integer: the same eight bits can represent 255 as a byte or −1 as a signed vector.
int and uint have fixed 32-bit widths, independent of the simulator’s native integer representation. Their ranges are −2,147,483,648 through 2,147,483,647 and zero through 4,294,967,295 respectively. Use an explicit type at public fields, parameters, and return values so callers can see the intended range and shape.
clock and reset identify timing signals rather than general data. They can be observed as scalar logic; converting scalar logic to reset requires as reset. Logic-to-clock and clock/reset interchange are not supported conversion paths. A cast does not create a clock source or synchronise a signal. See Contexts for connecting clocks and resets.
Declare values and constants
Choose a declaration according to whether the value must be known at compile time and whether a runtime binding can change. Type inference is available for local bindings, but it does not remove the need to understand the resulting type.
| Declaration | Meaning | Typical choice |
|---|---|---|
const | A compile-time value | A protocol width, mask, or fixed table. |
let | A runtime local binding that cannot be reassigned | A sampled value or intermediate result that stays fixed during this execution. |
var | A mutable runtime local | A loop counter or accumulator that is updated. |
let header: logic[16] = 0xA53C
var version = header[12:16]
var flags = header[8:12]
let payloadLength = header[0:8] as uint
Here the header width is explicit. The slice bounds establish four-bit results for version and flags, while the cast gives payloadLength an unsigned numeric interpretation. Hover over a local in Visual Studio Code to inspect the inferred type and hardware width. The editor may display the core spelling Logic[4]; application code can use logic[4].
Choose literals that fit
Decimal, hexadecimal, and binary notation make different aspects of a value readable. Use decimal for counts, hexadecimal for byte boundaries, and binary for individual flags. Underscores can separate groups in binary literals. A literal must have a supported conversion to its destination and fit that destination’s range.
let count: uint = 60
let marker: byte = 0xFF
let mask: logic[8] = 0b1111_0000
let nibble: logic[4] = 0xF
For example, var value: byte = 256 is rejected because 256 does not fit in eight bits. A negative literal does not fit uint, and a two-bit literal does not fit scalar logic. Logic-state literals such as 0bX belong to logic types and cannot initialise numeric types. An explicit runtime narrowing cast has different behaviour, explained below.
Understand what let protects
let requires an initializer and prevents whole-binding assignment, compound updates, and increment or decrement. The initializer executes when control reaches the declaration; it can include a scheduled call, so let is not a compile-time constant. It does not make the contents of an array or component deeply immutable.
var payload: byte[3] = [0x10, 0x20, 0x30]
let snapshot = payload
snapshot[0] = 0xAA
assert payload[0] == 0x10
assert snapshot[0] == 0xAA
A primitive-array local captures an array value. Updating the snapshot does not update the original. By contrast, a local bound to an existing component refers to that same hardware instance, so calling its functions can change shared component state. These value/reference rules apply to both let and var. A let adds a write restriction; it does not promise smaller hardware or lower latency.
A whole let binding cannot be passed where a call may write back to it, including out, inout, or a directionless primitive-array parameter. Use in for read-only access or a mutable local when updates are intended. Indexed elements remain writable. In foreach (let item in values), each iteration supplies a new binding; a loop counter updated with index++ needs var.
Write expressions
An expression combines values to calculate a result. Choose operators that match the meaning of the operands: comparisons produce conditions, logical operators combine conditions, and bitwise operators manipulate individual bits. Arithmetic requires a defined numeric domain rather than an assumption that the destination will choose one.
| Operators | Purpose |
|---|---|
+ - * / % | Arithmetic on compatible numeric operands. |
== != | Equality and inequality; the result is boolean. |
< <= > >= | Ordering comparisons for supported types. |
! && || | Logical negation, AND, and OR. |
& | ^ ~ | Bitwise AND, OR, XOR, and inversion. |
<< >> | Bit shifts. |
= += -= *= /= %= | Assignment and arithmetic updates. |
++ -- | Increment and decrement. |
let length: int = 60
let validLength = (length > 0) && (length <= 255)
let reject = !validLength
let flags: byte = 0xA5
let lowBits = flags & 0x0F
let highBits = flags & 0xF0
let inverted: byte = ~flags
&& and || short-circuit: the right-hand expression is evaluated only when needed. Bitwise & and | operate on bits instead. Use a comparison such as valid == 0b1 to express a signal-level condition explicitly. ! negates a boolean or scalar logic value; ~ flips the bits of a supported numeric or vector value and is not boolean negation.
Shifting a logic vector preserves its width, fills vacated positions with zeros, and discards bits shifted beyond the vector boundary. If signed numeric behaviour is required, make that interpretation explicit before the operation. Compound assignments update the destination; for a byte target, arithmetic compound updates retain only the low eight bits.
Use parentheses around casts and when mixing masks, arithmetic, and comparisons. (flags & 0x0F) == 0x05 makes the intended comparison visible without asking a reader to recall operator precedence. The same discipline helps when a slice feeds a conversion or arithmetic expression.
Control width and conversions
Some assignments accept a value without an explicit cast, such as a byte assigned to an integer or an eight-bit logic vector. That does not establish a general promotion rule for expressions. Select the numeric type of arithmetic operands explicitly, especially when combining bytes with integers. A standalone byte addition does not choose its arithmetic domain from the eventual return or assignment type.
let left: byte = 200
let right: byte = 100
let total = (left as int) + (right as int)
assert total == 300
var wrapped: byte = left
wrapped += right
assert wrapped == 0x2C
The first expression retains the full result in signed 32-bit arithmetic. The compound update stores into a byte, so 300 becomes 44. Cast before an operation when you need a wider intermediate result; widening a result after bits have already been discarded cannot recover them.
Make narrowing deliberate
let value: int = 300
let lowByte = value as byte
assert lowByte == 0x2C
let nibble: logic[4] = 0b1010
let extended = nibble as logic[8]
assert extended == 0x0A
A narrowing numeric cast retains the low bits without checking that the original value fits. Widening a logic vector adds zeros on the high side. Check the range before narrowing when losing information would be an error. The Livt core library includes conversion helpers with explicit clamped, wrapping, and truncating behaviour, and Livt.Bits.SignExtend for deliberate sign extension.
Choose signed or unsigned interpretation
let bits: logic[8] = 0xFF
assert (bits as int) == -1
assert (bits as uint) == (255 as uint)
let data: byte = 0xFF
assert (data as int) == 255
Converting a logic vector to int interprets its highest bit as the sign bit. Converting it to uint uses an unsigned interpretation. A byte is already unsigned, so converting 0xFF from byte to int gives 255. Make the choice at the boundary where raw bits become a number.
Boolean conversions also change meaning and require as: numeric zero becomes false and a nonzero numeric value becomes true; true converts to one and false to zero. Use numeric conversions on defined zero/one logic patterns rather than treating simulation states such as unknown or high impedance as numbers.
Conversion reference
| Source | Common supported targets |
|---|---|
bool | int, uint, byte, logic, logic[N] |
int or uint | The other integer type, bool, byte, logic, logic[N] |
byte | bool, int, uint, logic[N] |
logic | bool, int, uint, byte, logic[N] |
logic[N] | bool, int, uint, byte, logic, logic[M] |
Casting a byte directly to scalar logic is not supported. To select a particular bit, first convert to a vector, then index that vector: let bits = value as logic[8], followed by bits[0]. A scalar byte itself cannot be indexed. Identity casts are valid but usually unnecessary; strings and named types have separate rules described below.
Work with arrays and bit vectors
An array has a statically resolved shape. byte[3] is three byte elements, whereas logic[16] is one sixteen-bit vector. A multidimensional declaration such as logic[4, 2] describes four two-bit vectors. Use a binary or hex literal for a vector and bracket-list literals for arrays of elements.
var payload: byte[3] = [0x10, 0x20, 0x30]
var patterns: logic[4, 2] = [0b00, 0b01, 0b10, 0b11]
var matrix: byte[2, 3] = [
[0x01, 0x02, 0x03],
[0x04, 0x05, 0x06]
]
payload[0] = 0x55
matrix[1, 2] = 0xFF
Indices start at zero. An initializer can establish an omitted extent, as in var values: int[] = [0, 14, 34]. An unconstrained field can also receive its concrete shape through statically known construction. The final hardware still needs a positive extent: zero-length arrays, empty array values, and unsized storage without a resolvable extent are not supported. Represent an empty logical collection with a separate length or valid flag rather than zero physical storage.
Extract fields with slices
A slice uses value[start:stop], including start and excluding stop. The width is the difference between the bounds. For the packet header below, bit zero is the least-significant bit; selecting [12:16] extracts the four version bits.
let header: logic[16] = 0xA53C
var version = header[12:16]
var flags = header[8:12]
let length = header[0:8] as uint
assert version == 0b1010
assert flags == 0b0101
assert length == (60 as uint)
Literal bounds give the compiler a concrete result width. For variable positions, keep the intended width fixed and explicit, such as logic[4] for a four-bit window, and ensure the chosen bounds stay within the source. A type annotation does not turn hardware storage into a dynamically growing value or make arbitrary bounds safe. Slices can also select a writable subrange of a mutable vector.
An omitted start means zero, and an omitted stop means the source extent. A negative integer-literal index counts from that extent: for this 16-bit header, header[-4:] is the same as header[12:16], and header[-1] selects bit 15. Step/reverse forms such as [::2] and [::-1], and negative variable bounds such as [-offset:], are not supported. Empty or reversed constant ranges are invalid. Scalars such as byte, int, and logic cannot be sliced.
Concat combines one-dimensional logic vectors. The receiver occupies the low bits and the argument supplies the high bits. In our example, the following expression therefore reconstructs the upper byte of the header:
var flags: logic[4] = 0b0101
var version: logic[4] = 0b1010
var combined = flags.Concat(version)
assert combined == 0xA5
When both widths are known, the result width is their sum. The mutable locals also satisfy the method’s array-parameter contract. For reusable width parameters, see Execution models. Array shape alone does not establish whether storage becomes registers or a particular RAM resource; see Hardware integration for checking memory placement.
Pass arrays to functions
An array parameter’s direction tells callers whether the function reads existing contents and whether updates are copied back. Use an explicit direction on public APIs so those effects are visible.
| Direction | Reads supplied contents | May write | Caller receives updates |
|---|---|---|---|
| Omitted | Yes | Yes | Yes |
in | Yes | No | No |
out | Not required | Yes | Yes |
inout | Yes | Yes | Yes |
component PacketFields
{
public fn ReplaceFirst(values: inout byte[], value: byte)
{
values[0] = value
}
public fn First(values: in byte[]) byte
{
return values[0]
}
}
After ReplaceFirst completes, the caller observes the updated first element. First reads without changing the array and can accept an immutable binding. A directionless primitive-array parameter is mutable too; omitting the direction does not make it read-only.
An unconstrained parameter such as byte[] can receive different statically known lengths. Length() reports the supplied extent; unconstrained return types obtain their extent from the returned value. For multiple dimensions, query values.Length() for the first dimension and values[0].Length() for the next. These forms do not allocate new unsized storage.
Give values a meaning
A type can express what a value means as well as how it is represented. Enums give names to a fixed set of choices, such as the states of a packet processor. Distinct types separate values that share a representation but serve different purposes, such as a payload length and an address. Both make APIs easier to understand and help the compiler detect unintended combinations.
Use enums for named choices
namespace Examples.Packets
enum PacketState
{
Idle
Ready
Done
}
Use an enum when a value represents one of a finite set of choices. Refer to members through the type, such as PacketState.Ready, and compare values of the same enum with == or !=. Members are immutable values, not functions or components to construct. Declare at least one member and give each member a unique name.
Enums can appear in fields, locals, constants, arrays, parameters, and return values. Where a default is supplied, it is the first declared member; explicitly initialise important state. Enums do not support numeric arithmetic, ordering, or numeric encoding through as. The VHDL backend emits an enumeration, and synthesis chooses its representation. Declaration order is not a promised wire encoding; use explicit bit values for a protocol contract.
Use distinct types for different meanings
A payload length and an address might both use unsigned integers, but they should not be interchangeable in an API. A namespace-level distinct type preserves that difference while retaining an underlying representation.
namespace Examples.Packets
type PayloadLength : UnsignedInteger {}
type Address : UnsignedInteger {}
The colon names the representation, not a parent class. The supported representations are Integer, UnsignedInteger, Byte, Boolean, and Logic. Declare an empty body. A distinct type is neither an alias nor a component, and new PayloadLength(...) is not a construction form.
let length = (60 as uint) as PayloadLength
let raw = length as uint
let copy: PayloadLength = length
assert copy == length
Wrapping and unwrapping require as and the exact representation type. Convert numerically first, then wrap. To change from one distinct meaning to another, explicitly unwrap and rewrap. Values of the same distinct type can be assigned and checked for equality; arithmetic, ordering, and logical operations require unwrapping. The declaration adds no range check, and its default follows the representation’s zero value.
Distinct values can be fields, constants, parameters, return values, and generic type arguments. Arrays retain their element identity and can be explicitly converted to or from representation arrays of the same shape. Such a conversion does not resize the array or numerically convert its elements. The VHDL backend uses the representation without an extra type tag or wrapper storage, while ordinary function calls retain their usual scheduling.
Use text and encoded data
Use strings for simulation reports, fixed messages, and text that will become encoded data. When hardware must transmit text, encode it into bytes so its storage and interface are explicit. For example, this component holds a fixed greeting:
component TextConstants
{
const GREETING: byte[] = "Hello".Encode()
public fn GetLength() int
{
return GREETING.Length()
}
}
Triple-quoted strings keep multiline content readable. Livt removes common indentation introduced by the surrounding source layout and preserves relative indentation within the content. They are useful for templates and fixed data; ordinary strings remain clearer for short messages.
const MESSAGE: byte[] = """
Packet ready
Waiting for transfer
""".Encode()
Text used in Simulation.Report supports observation during simulation. It does not by itself define a synthesizable text-processing system. The Livt core library provides encoding, formatting, and conversion helpers.
Check your assumptions
Use editor diagnostics to catch incompatible types and out-of-range literals before simulation. The following screenshot shows a byte initialised with 256. Select the diagnostic in Problems to open its source and correct either the value or the chosen type.
Then verify the cases where code is valid but the result could be surprising. The complete TypesDemo project checks header slices and concatenation, signed and unsigned conversion, truncation and byte updates, array copying and parameter updates, and distinct types and enums. Extract it, open its folder in Visual Studio Code, and run PacketFieldsTest from Livt Tests or Test Explorer. All four tests should pass.
Type choices also affect the hardware cost of an expression. Wider arithmetic can require more resources, and division or multiplication can affect timing. A passing functional test establishes the checked values, not routed timing or a particular physical memory mapping. Continue with Control Flow to use these values in decisions and sequences, and Testing to extend the verification.