Types
SafeC provides a strong, statically-typed type system with no implicit conversions (except safe numeric widening — smaller to bigger, never the reverse). All types are resolved at compile time; generics are fully monomorphized with zero runtime overhead.
Primitive Types
| Type | Size | Description |
|---|---|---|
int | 32-bit | Signed integer |
long / long long | 64-bit | Signed long integer |
float | 32-bit | IEEE 754 single-precision |
double | 64-bit | IEEE 754 double-precision |
bool | 1-bit | true or false |
char | 8-bit | Character (unsigned byte) |
void | 0-bit | Unit type / no value |
Sized Integer Types
For explicit control over width and signedness:
| Signed | Unsigned | Width |
|---|---|---|
int8_t | uint8_t | 8-bit |
int16_t | uint16_t | 16-bit |
int32_t | uint32_t | 32-bit |
int64_t | uint64_t | 64-bit |
Type Inference with auto
auto infers a local variable's type from its initializer — the variable must have one (auto x; with no initializer is a compile error):
auto n = 42; // int
auto pi = 3.14; // double
auto p = compute(); // whatever compute() returns
for (auto i = 0; i < 10; i++) {
// i : int
}auto only infers from the initializer expression's own type — it doesn't do any broader flow analysis, and (like every other declared type) the inferred type is fixed for the variable's lifetime; there's no re-inference on reassignment. It works anywhere an ordinary declared-type local variable would — including a for loop's init clause, as shown above.
Struct Types
Structs are value types with C-compatible layout. Assignment copies the entire value.
struct Point {
double x;
double y;
double length() const;
void scale(double s);
};Structs support methods (see Functions), operator overloading, and can be used as generic type arguments.
Union Types
Every union in SafeC is a tagged union: alongside the fields you declare, the compiler stores a hidden discriminant recording which field is actually live, and enforces that you can only read the field you most recently wrote (via match, not direct field access — see below). This is a deliberate departure from C's union, where reading whatever field you like regardless of which one was last written is legal (if type-punning-dependent) — SafeC doesn't allow that, in keeping with its general "no undefined behavior in safe code" stance.
union Result {
int ok;
int err;
}Construct and read through Type.field(value) / match, not plain .field assignment
Because every union is tagged, union Result r; r.ok = 42; followed by plain-field reads doesn't behave like a C union (and can produce outright wrong values for non-int-sized fields, due to how the discriminant and payload are laid out) — it isn't the sanctioned way to use one. Construct a union value with TypeName.field(value), and read it back with match's dot-prefixed variant patterns, case .field(x)::
union Result {
int ok;
int err;
}
void handle(union Result r) {
match (r) {
case .ok(v): printf("ok: %d\n", v);
case .err(e): printf("err: %d\n", e);
default: printf("unreachable\n");
}
}
int main() {
union Result a = Result.ok(42);
union Result b = Result.err(-1);
handle(a); // ok: 42
handle(b); // err: -1
return 0;
}This is the same .variant(x) pattern shape ?T/?®ion T's some(x) uses (see "Reading a nullable value" above), minus the leading dot there — some/none are plain identifiers, not dot-prefixed, unlike a tagged union's own variant names.
Unions can be generic too, the same as structs: generic<T, E> union Result { T ok; E err; } is a real sum type over an arbitrary pair of types, monomorphized per concrete <T, E> instantiation like any other generic type.
Tagged unions plus exhaustive match are SafeC's algebraic-data-type / closed-polymorphism mechanism — see Polymorphism & OOP and Functional Programming.
Tuple Types
Tuples are anonymous product types. Members are accessed by index using .0, .1, etc.
tuple(int, double) pair = (42, 3.14);
int first = pair.0;
double second = pair.1;Tuples lower to anonymous LLVM struct types at codegen.
Slice Types
A slice is a fat pointer consisting of a data pointer and a length. Slices provide bounds-checked access to contiguous memory.
int arr[5] = {10, 20, 30, 40, 50};
[]int s = arr[1..4]; // {int*, i64} with len=3
int x = s[0]; // bounds-checked access
long len = s.len; // length field
int *raw = s.ptr; // underlying pointerOptional Types
Optional types represent values that may or may not be present. They lower to a {T, i1} pair.
?int find_first(int *arr, int n, int target) {
int i = 0;
while (i < n) {
int v;
unsafe { v = arr[i]; }
if (v == target) return i; // implicit T -> ?T wrap
i = i + 1;
}
return null; // the empty case
}
// Usage with try (propagates the empty case to the caller)
?int wrapper(int *arr, int n, int target) {
int val = try find_first(arr, n, target);
return val * 2;
}A plain T implicitly wraps to ?T (as in return i; above), and null is the empty value for both ?T optionals and nullable references — there is no separate some(x)/none constructor syntax; those two identifiers only appear as match patterns (see Control Flow), not as general expressions.
Nullable references use the same ? syntax:
?&stack Node next; // nullable stack referenceReading a nullable value
A pointer (T*), nullable reference (?®ion T — "region" here also covers the region-less ?&T form, see Memory & Regions's "Outliving References" section), or optional (?T) cannot be dereferenced, member-accessed, or force-unwrapped directly — the compiler requires one of the following:
| Operation | Works on | Effect |
|---|---|---|
x.is_null() | T*, ?®ion T | Returns bool; presence check only, does not narrow x's type |
x.is_none() | ?T | Returns bool; the optional's equivalent of is_null() |
x.default(fallback) | all three | Returns the inner value if present, else evaluates and returns fallback (must match the inner type) |
match (x) { case null / none: ... case some(v): ... } | all three | v is bound directly as the inner type inside the some arm |
x! / *x / x.field / x->field inside unsafe { } | all three | Bypasses the checks above entirely |
struct Node { int value; };
int describe(?&stack Node n) {
// int v = n.value; // ERROR: requires 'unsafe', or match/is_null()/.default(value)
// Node v = n!; // ERROR: '!' force-unwrap requires 'unsafe'
if (n.is_null()) { return -1; } // OK: presence check
Node fallback;
fallback.value = -1;
Node result = n.default(fallback); // bind first — chaining .default(...).value
return result.value; // directly doesn't compile (temporary receiver)
// OK: unsafe bypasses the checks entirely
// unsafe { return n->value; }
}
int describe_match(?&stack Node n) {
return match (n) {
case null: -1,
case some(v): v.value, // v : Node (a copy of the payload) -- bound by value, not by reference
};
}x.is_null() on a ?T (and x.is_none() on a pointer/nullable reference) is a compile error — use the one matching the receiver's kind. match with null/some(x) patterns works on raw pointers (T*) too, not just nullable references.
See Functional Programming for try-based propagation and what ?T deliberately doesn't offer (no .map()/.and_then() combinators).
Newtype Distinct Types
Newtypes create distinct types from a base type. They are not interchangeable with their base.
newtype UserId = int;
newtype Temperature = double;
UserId id = (UserId)42; // explicit cast — no UserId(42) constructor-call syntax
// int x = id; // ERROR: UserId is not intEnum Types
Enums with explicit underlying type:
enum Color : uint8_t {
Red = 0,
Green = 1,
Blue = 2
}
enum Status : int {
OK = 200,
NotFound = 404,
ServerError = 500
}Function Types
Function pointers use the fn keyword:
fn int(int, double) compute;
fn int(int) transform = add_one;
int result = transform(5); // calls add_one(5)Region-Qualified Reference Types
References carry region information that the compiler uses for lifetime analysis:
&stack int // non-null stack reference
&heap float // non-null heap reference
&static Config // non-null static reference
&arena<AudioPool> Frame // non-null arena reference
&Point // non-null reference, no region -- accepts any of the above
?&Point // nullable, no regionLeaving off the region qualifier entirely (&T / ?&T) gives a reference with no declared or tracked region — see Memory & Regions's "Outliving References" section for when that's the right choice over pinning a specific region.
See Memory & Regions for details.
Generic Types
Generics are compile-time only and fully monomorphized. No vtables or runtime dispatch.
generic<T: Numeric>
T add(T a, T b) {
return a + b;
}
// The compiler generates separate versions:
// int add(int a, int b)
// double add(double a, double b)Generic type parameters can be constrained with traits:
generic<T: Numeric>
T clamp(T val, T lo, T hi) {
if (val < lo) return lo;
if (val > hi) return hi;
return val;
}Type Conversions
SafeC has no implicit conversions with one exception: safe widening — any smaller numeric type to a bigger one, across integer, float, and integer→float. Narrowing (bigger → smaller, or float → integer) still requires an explicit cast:
int x = 42;
double d = x; // implicit: int -> double widens safely
float f = 3.14f;
double d2 = f; // implicit: float -> double widens safely
int i = (int)f; // explicit cast required: float -> int narrows
long long big = 100LL;
int small = (int)big; // explicit cast required: narrowingValue vs Reference Semantics
- Structs are value types: assignment copies the entire struct
- References are explicit: you must use
&to create a reference and region-qualify it - No hidden move semantics: what you write is what happens
- Arrays decay to pointers when passed to functions, following C convention
struct Point { double x; double y; };
Point a = {1.0, 2.0};
Point b = a; // copies the struct
b.x = 99.0; // does not affect a
&stack Point ref = &a; // explicit reference