Variables and Basic Types
Variables give names to values. Every variable in Sec has a type, a scope, and a defined mutability. Sec is statically typed, so the compiler determines the type of every variable during compilation. A variable cannot silently change from one type to another while the program is running.
Sec variables are immutable by default. This is a deliberate safety rule rather than a
restriction added for style. A value can only be reassigned when the declaration explicitly
includes mut.
Declaration and Type Inference
A variable may be declared with an initial value. When the type is obvious from that value, the compiler can infer the type.
Every variable declaration must begin with let. The declaration keyword is
mandatory even when the compiler can infer the variable's type from its initial value.
Sec does not permit a bare name followed by := to introduce a variable.
let declares an immutable variable.
let mut declares a mutable variable. The operator := assigns
the initial value as part of the declaration.
Valid Declarations
let b := 8 // immutable, inferred type
let b: int := 9 // immutable, explicit type
let mut b: int // mutable, explicit type, not initialized here
let mut b := 10 // mutable, inferred type
Invalid Declarations
b := 8 // compile-time error: missing let
The compiler treats the final example as invalid because := is not itself a
declaration keyword. Requiring let makes every declaration immediately visible
and distinguishes declarations from later assignments.
let name := "Anna"
let age := 42
let enabled := true
In this example, the compiler infers that name is a string,
age is an integer type, and enabled is a bool.
Type inference does not make the language dynamically typed. The inferred type is fixed
at compile time and remains the type of the variable throughout its lifetime.
A type may also be written explicitly when the programmer wants to document the intended representation or when inference would be ambiguous.
let retryCount: int32 := 3
let price: decimal := 19.95
let ratio: float64 := 0.75
let value := 10
value = 20 // same type
value = "twenty" // compile-time type error
Immutable by Default
A normal variable declaration creates an immutable binding. After the variable has received its initial value, that binding cannot be assigned another value.
let port := 8080
port = 8081 // compile-time error
Immutability makes the flow of data easier to understand. When a reader encounters an immutable variable, they know that the name continues to refer to the same value for the remainder of its scope. The reader does not need to search the entire function for later assignments before understanding the current value.
Immutable-by-default code also helps the compiler. A value that cannot change is easier to analyze, easier to optimize, and safer to share with other parts of a program.
Binding Immutability
Immutability applies to the variable binding. It means that the variable cannot be assigned a different value. Whether data reachable through a reference or contained in a more complex type can change is governed by the rules of that type and Sec's reference and ownership model.
For basic value types, the result is straightforward: an immutable variable cannot be changed after initialization.
let count := 5
count += 1 // compile-time error
mut.
Mutable Variables
Add mut when a variable must receive a new value after initialization.
Mutability is therefore visible at the declaration site rather than discovered later by
reading assignments elsewhere in the function.
let mut retryCount := 0
retryCount += 1
retryCount = 0
The mut modifier permits reassignment. It does not weaken static typing and does
not permit the variable to contain arbitrary types.
let mut retryCount := 0
retryCount = 3 // valid
retryCount = "three" // compile-time type error
Mutability should describe a real requirement of the algorithm. It should not be added automatically to every declaration. Values such as configuration, identifiers, function inputs, and intermediate results often do not need to change.
Use the Smallest Mutable Scope
When mutation is necessary, keep the mutable variable in the smallest practical scope. This limits the amount of code that can change it and makes its possible states easier to reason about.
let total := calculateBaseTotal(order)
{
let mut adjustedTotal := total
adjustedTotal += calculateTax(order)
adjustedTotal -= calculateDiscount(order)
print(adjustedTotal)
}
Assignment
Assignment replaces the current value of a mutable variable. The assigned expression must be assignable to the variable's declared type.
let mut temperature: float64 := 18.5
temperature = 19.0
Compound assignment operators read the current value, apply an operation, and assign the result back to the same variable. Because they perform assignment, they also require a mutable variable.
let mut count := 10
count += 5
count -= 2
count *= 3
count /= 2
count %= 4
Sec does not use the increment and decrement operators ++ and --.
Use explicit compound assignment instead.
let mut index := 0
index += 1
Scope
A variable exists only inside its scope. A block delimited by { and }
creates a nested scope. A name declared in that block is not available after the block ends.
fn Example() void {
let message := "outside"
{
let detail := "inside"
print(message)
print(detail)
}
print(message)
print(detail) // compile-time error: detail is out of scope
}
Function parameters are local symbols in the function body. They are immutable by default, just like other variables.
fn PrintUser(name: string, age: int) void {
print(name)
print(age)
age = 43 // compile-time error
}
Shadowing
Shadowing occurs when a nested scope declares a new variable with the same name as a variable in an outer scope. The inner declaration is a different variable; it does not mutate the outer variable.
let value := 10
{
let value := 20
print(value) // 20
}
print(value) // 10
Shadowing can be useful when a name represents the same conceptual role at a more specific stage of processing. It should not be used to hide unrelated values, because that makes code harder to read.
Initialization
A variable must contain a valid value before it is read. An initializer establishes the variable's first value and normally establishes its inferred type.
let name := "Anna"
let count := 0
Basic types have defined zero or empty values where applicable. Domain-specific and constrained types may only have a default value when that value satisfies the type's rules. A later chapter describes default values and constrained types in detail.
Basic Types
Basic types are built directly into the language. They form the foundation used to construct domain types, structures, collections, interfaces, and other abstractions.
The decided basic types are:
bool
byte
char
decimal
float
float32
float64
int
int8
int16
int32
int64
uint
uint8
uint16
uint32
uint64
rune
string
void
any
bool
bool represents a logical truth value. It has exactly two values:
true and false.
let enabled := true
let finished := false
Conditions in if, while, logical operators, and match guards require
boolean expressions. Sec does not implicitly convert integers, strings, references, or other
values into booleans.
let count := 3
if count { // compile-time error
}
if count > 0 { // valid
}
The logical operators are:
!value
left && right
left || right
Comparison operators such as ==, !=, <,
<=, >, and >= produce bool results
when they are supported by the operand types.
The default value of bool is false.
byte
byte represents one byte of raw data. It is intended for binary formats,
buffers, network data, encoded files, cryptographic material, and other data where the
individual unit is a byte rather than a number with domain meaning.
header: byte = 0x7F
Although a byte can be interpreted numerically, byte communicates that the value
is part of a binary representation. Use uint8 when the value is conceptually an
unsigned eight-bit integer and byte when it is conceptually raw data.
packetType: byte = 0x02
retryCount: uint8 = 2
Text should normally use string, char, or rune rather than
byte. A UTF-8 character may occupy more than one byte.
The default value of byte is zero.
char
char represents a character value. It is used when code works with a single
textual character rather than a complete string.
separator: char = ','
answer: char = 'Y'
A character literal uses single quotes. A string literal uses double quotes.
letter: char = 'A'
word: string = "A"
char and rune are distinct types. Use rune when the code
explicitly works with Unicode code points. Use char when the value is treated as a
character in normal text processing. Conversion between the types must remain explicit when
required by an API.
A character is not interchangeable with a one-character string. The former is one value; the latter is a string containing one textual element.
decimal
decimal represents decimal numeric values. It is intended for calculations where
decimal representation and predictable base-10 behavior are more important than the speed or
range characteristics of binary floating-point arithmetic.
let price: decimal := 19.95
taxRate: decimal = 0.25
let total := price + price * taxRate
Typical uses include money, tax, accounting, measurements expressed and rounded in decimal, and business rules that require decimal precision.
Binary floating-point types cannot represent every decimal fraction exactly. For example,
a value such as 0.1 generally has an approximate binary representation. Code that
requires decimal semantics should therefore use decimal rather than float,
float32, or float64.
unitPrice: decimal = 0.10
quantity: decimal = 3
let total := unitPrice * quantity
decimal is also the natural foundation for semantic types with units or currencies.
type SEK decimal<SEK>
type Meter decimal<m>
The default value of decimal is decimal zero.
float
float is the general binary floating-point type. It is suitable for scientific,
graphical, statistical, simulation, and engineering calculations where fractional values,
large ranges, and floating-point performance are required.
temperature: float = 21.5
ratio: float = 2.0 / 3.0
Floating-point values are approximate. Equality comparisons should therefore be used with care when results have passed through arithmetic operations.
let result := calculateValue()
let closeEnough := abs(result - expected) < tolerance
Use float32 or float64 when an exact storage width is part of the data
format, external interface, memory layout, or numerical requirements. Use float when
the exact width is not the important part of the program's meaning.
The default value of float is floating-point zero.
float32
float32 is a 32-bit binary floating-point type. It uses less storage than
float64 but provides less precision and a smaller numeric range.
vertexX: float32 = 12.5
vertexY: float32 = -4.75
Common uses include graphics data, machine-learning tensors, large numeric arrays, external binary formats, and hardware interfaces that explicitly use 32-bit floating-point values.
Do not select float32 merely because the current values appear small. Precision
requirements, accumulated error, external representation, and memory cost should determine
the choice.
The default value of float32 is 0.0.
float64
float64 is a 64-bit binary floating-point type. It provides greater precision
and range than float32 at the cost of additional storage.
latitude: float64 = 59.3293
longitude: float64 = 18.0686
It is appropriate when numerical precision is important, when calculations accumulate error, or when an external API or file format explicitly requires a 64-bit floating-point value.
Greater precision does not make floating-point arithmetic exact. Values are still represented
in binary and should not be used as a substitute for decimal where decimal semantics
are required.
The default value of float64 is 0.0.
int
int is the general signed integer type. It represents whole numbers that may be
negative, zero, or positive.
let temperature := -5
let count := 42
let offset := 0
Use int for ordinary counting, indexing, differences, offsets, and calculations
when the exact bit width is not part of the data's meaning or external representation.
Use one of the fixed-width signed integer types when the width is required by a protocol, file format, memory layout, hardware interface, or interoperability boundary.
itemCount: int = 120
protocolValue: int32 = -120
The default value of int is 0.
int8
int8 is an 8-bit signed integer. Its values range from -128 through 127.
adjustment: int8 = -12
Use int8 when an external representation or compact memory layout specifically
requires an 8-bit signed value. For ordinary arithmetic, int is usually clearer.
The default value of int8 is 0.
int16
int16 is a 16-bit signed integer. Its values range from -32,768 through 32,767.
sample: int16 = -2048
It is commonly useful for binary formats, audio samples, device registers, compact arrays, and APIs that define values as signed 16-bit integers.
The default value of int16 is 0.
int32
int32 is a 32-bit signed integer. Its values range from -2,147,483,648 through
2,147,483,647.
databaseValue: int32 = 1500000
Use it when a stable 32-bit representation is required, such as in external data formats, database mappings, network protocols, foreign-function interfaces, or memory-sensitive collections.
The default value of int32 is 0.
int64
int64 is a 64-bit signed integer. It supports a substantially larger range than
int32 and is suitable for large counters, timestamps, file positions, identifiers,
and external formats that specify signed 64-bit values.
fileSize: int64 = 5000000000
timestamp: int64 = 1783670400
A large available range does not automatically make int64 the best type for every
integer. Prefer int for normal internal arithmetic unless the width or range is part
of the requirement.
The default value of int64 is 0.
uint
uint is the general unsigned integer type. It represents zero and positive whole
numbers, but cannot represent negative values.
mask: uint = 15
capacity: uint = 1024
Unsigned types are appropriate when bit-level operations or an external representation specifically require unsigned values. They should not be selected only because a value is expected to remain non-negative. Differences and error states may naturally require negative values even when the original quantities do not.
available: uint = 5
required: uint = 8
// available - required needs careful handling because the mathematical result is negative.
The default value of uint is 0.
uint8
uint8 is an 8-bit unsigned integer. Its values range from 0 through 255.
channel: uint8 = 255
Use uint8 for compact unsigned numeric values and external representations that
explicitly require eight bits. Use byte instead when the value represents raw binary
data rather than a number.
The default value of uint8 is 0.
uint16
uint16 is a 16-bit unsigned integer. Its values range from 0 through 65,535.
registerValue: uint16 = 65535
It is useful for device registers, network fields, encoded dimensions, compact counters, and other representations defined as unsigned 16-bit values.
The default value of uint16 is 0.
uint32
uint32 is a 32-bit unsigned integer. Its values range from 0 through
4,294,967,295.
flags: uint32 = 0x80000000
Typical uses include bit masks, protocol fields, checksums, binary file formats, and foreign interfaces that require a stable unsigned 32-bit representation.
The default value of uint32 is 0.
uint64
uint64 is a 64-bit unsigned integer. It provides a very large non-negative range
and is used when an external specification, identifier space, counter, or bit representation
explicitly requires 64 unsigned bits.
sequenceNumber: uint64 = 10000000000
Arithmetic involving signed and unsigned values must remain explicit. The compiler should not silently reinterpret a negative signed value as a very large unsigned value.
The default value of uint64 is 0.
rune
rune represents a Unicode code point. It is intended for Unicode-aware text
processing where code must examine, compare, transform, or classify individual code points.
letter: rune = 'Å'
symbol: rune = '界'
UTF-8 is a variable-length encoding. A single rune may occupy more than one byte when encoded as UTF-8. Consequently, indexing or counting bytes is not the same as indexing or counting Unicode code points.
let text := "Ångström"
// The UTF-8 byte length and rune count are different concepts.
A rune is not a string. A string may contain zero, one, or many runes. Use string
for complete text and rune when operating on an individual Unicode code point.
string
string represents UTF-8 text. String literals use double quotes.
let name := "Jonas"
let message := "Sec is statically typed."
Strings are text, not arbitrary byte buffers. Use binary containers or byte-oriented types for compressed data, encrypted data, file contents that are not known to be text, and network packets.
String interpolation uses a dollar sign before the string literal. Expressions inside braces are evaluated and inserted into the resulting string.
let name := "Anna"
let count := 3
let message := $"Hello {name}, you have {count} messages."
Because strings use UTF-8, byte length, rune count, and user-perceived character count are separate concepts. APIs should name these operations clearly rather than treating them as one generic length operation.
The default value of string is the empty string "".
void
void indicates that a function does not return a value. It is used as a function
return type, not as a normal value that can be stored in a variable.
fn PrintGreeting(name: string) void {
print($"Hello {name}")
}
A void function may finish at the end of its body or use return without
an expression.
fn PrintIfPresent(value: string) void {
if value == "" {
return
}
print(value)
}
Returning an expression from a void function is a compile-time error. Likewise, a
caller cannot use the call as though it produced a normal value.
any
any can hold a value of any Sec type. It is intended for situations where the
value's concrete type is genuinely not known at compile time.
value: any = 42
text: any = "hello"
Using any removes information that the compiler would otherwise use to verify
operations. Code must determine or constrain the contained value before performing
type-specific operations.
Prefer a concrete type when all values share one representation. Prefer a generic type
parameter when the concrete type varies but is known during compilation. Prefer an interface
when code depends on a defined set of behaviors. Use any only when none of those
accurately describe the problem.
// Prefer a generic function when the type is known at compile time.
fn First[T](values: []T) T {
return values[0]
}
// Use any only when the value is genuinely dynamic.
fn ReadDynamicValue(source: ref Source) any {
// ...
}
Common legitimate uses may include dynamic document formats, debugging tools, generic serialization boundaries, reflection-related facilities, and interoperability with systems whose values are dynamically typed.
any is an escape from specific static type information,
not a replacement for designing useful types.
Choosing a Basic Type
Select a type according to the meaning and requirements of the value, not merely according to the smallest representation that can contain the current example.
| Requirement | Typical choice |
|---|---|
| Logical truth value | bool |
| Raw binary unit | byte |
| Single textual character | char |
| Unicode code point processing | rune |
| UTF-8 text | string |
| Ordinary signed whole number | int |
| Ordinary unsigned or bit-oriented whole number | uint |
| Externally defined integer width | int8 through int64, or uint8 through uint64 |
| General binary floating-point calculation | float |
| Externally defined floating-point width | float32 or float64 |
| Money or decimal business arithmetic | decimal |
| Function returns no value | void |
| Genuinely unknown runtime value | any |
Semantic types should be introduced when two values share a representation but have different meanings. For example, customer identifiers and product identifiers should not remain plain integers merely because both are stored as numbers.
type CustomerID int64
type ProductID int64
The compiler can then prevent accidental mixing of concepts that happen to share the same underlying representation.