Type System¶
Accepted
Accepted for V1 type values, concrete/constraint/sized categories, intersections, structural constraints, and reflection boundaries; advanced type-system work is deferred from V1.
Catalyst wants a composable type system without turning types into a second programming language.
The V1 built-in type inventory is documented in Built-In Types.
Goals¶
Keep:
- enum unions and error-set unions
- intersections
- structural composition
- narrowing
- inference
- explicit comptime parameters
Avoid:
- type-level execution
- recursive type metaprogramming
- conditional-type gymnastics
- hidden runtime polymorphism
Type Values¶
Type is the type of a comptime expression that is valid wherever source expects a type expression.
V1 has no separate type declaration keyword. Type aliases and constraints are ordinary constants of type Type:
const Sample: Type = f32
const HotSequence: Type = Sequence(f32) & RealtimeSafe
Generic types follow the same rule as other type factories: a generic type is an ordinary comptime function returning a Type value.
const Header = struct {
channels: u32
}
fn Buffer(comptime T: Type) Type {
return struct {
data: []T
}
}
Return type Type is always inferable for type and contract factory functions. The annotation is optional, including for public APIs:
fn Buffer(comptime T: Type) => struct {
data: []T
}
Semantic contracts use the same model. A non-generic contract is a const Type value; a generic contract is a comptime function returning a Type value.
Type values may be concrete types or constraint types:
i32 // concrete, sized Type
[]f32 // concrete, sized Type
dyn Iterator(i32) // concrete, unsized Type
Ordering // concrete enum Type
ReadError | AllocError // concrete error-set union Type
satisfies(.{ ... }) // structural constraint Type
Sequence(f32) // semantic contract constraint Type
Definitions:
- A concrete type can describe runtime values, as opposed to a compile-time-only constraint. Concrete does not imply sized or directly storable.
- A constraint type is a compile-time-only type expression that denotes a set of possible concrete types. Constraint types do not describe runtime values.
- A sized type is a concrete type whose value layout has compile-time-known inline size and alignment, so values can be stored directly in locals, fields, arrays, by-value parameters, and return slots.
- An unsized type is a concrete type whose values require indirection because inline size and/or alignment is not known from the type alone.
Concreteness and sizedness are reflected properties of a Type value, not separate source-level kinds:
T.is_concrete()
T.is_sized()
Context decides whether a constraint type or unsized concrete type is legal:
These forms are valid V1 runtime or static-parameter uses:
var x: f32 = undefined
var p: *dyn Iterator(i32) = undefined
var b: Box(dyn Iterator(i32)) = undefined
fn sum(xs: *const impl Sequence(f32)) f32
Constraint types and unsized concrete types are rejected for inline runtime storage:
var x: Sequence(f32) = undefined // error: constraint type, not a runtime value type
var x: dyn Iterator(i32) = undefined // error: unsized concrete type cannot be stored inline
dyn Contract forms a concrete unsized erased type when the applied contract is dyn-safe. Pointer and owner types around it are sized runtime values. For example, *dyn Iterator(i32) is a borrowed dynamic fat pointer, and Box(dyn Iterator(i32)) is an owning resource value.
V1 Box supports sized concrete payload types and dyn Contract payloads only. Other unsized concrete type families are not valid Box payloads until their metadata, allocation layout, construction, and destruction rules are designed.
The compiler-provided Type and reflection APIs are responsible for constructing and inspecting these values. Type is comptime-only and has methods for common reflection queries. Prelude declarations such as satisfies(...) and future contract-construction declarations may contain real comptime validation and setup logic over compiler-provided primitives. The compiler should recognize the resulting Type value and its metadata, not the name of the declaration that produced it.
A Type value is a handle to a compiler type-table entry. Type constructors such as struct { ... }, enum { ... }, non-empty error { ... }, and contract { ... } create type entries per evaluated semantic instance.
Re-evaluating the same cached semantic instance returns the same handle. Evaluating the same source constructor in a different generic instantiation or semantic instance creates a distinct handle. Aliases copy handles; they do not create new type entries.
Generic parameter annotations accept any Type value as a constraint:
fn id(comptime T: Type, value: T) T
fn sum(comptime S: Sequence(f32), xs: *const S) f32
fn draw(comptime P: satisfies(.{ x: f32, y: f32 }), p: P) void
The annotation after : is the allowed set of values for that comptime parameter. Type is the broadest type-expression constraint. A constraint Type value is not a member of itself:
fn accepts_sequence(comptime S: Sequence(f32)) void
accepts_sequence([]f32) // ok if []f32 implements Sequence(f32)
Passing the constraint itself as the implementer is invalid:
accepts_sequence(Sequence(f32)) // error: this is the constraint, not an implementer
V1 ordinary structural and semantic constraints range over concrete types only. Constrained type parameters receive concrete types satisfying the constraint. If code wants to inspect arbitrary type expressions, including constraint types, it should accept comptime T: Type.
A named comptime parameter may be self-referential in its own constraint annotation. This is a self-constrained parameter: inside the annotation, the parameter name denotes the candidate value being checked. The annotation may refer to that parameter and to earlier parameters, but not to later parameters:
fn double(comptime T: Add(T, T), value: T) T {
return value + value
}
fn scale(comptime Scalar: Type, comptime V: Mul(Scalar, V), value: V, factor: Scalar) V {
return value * factor
}
The annotation is evaluated with the parameter bound to the candidate value and must evaluate to a valid constraint for that parameter. In V1, the candidate is supplied explicitly; future inference may supply a candidate before this validation step. The constraint does not infer the candidate from the constraint alone. Cycles or failures while evaluating the annotation are ordinary comptime dependency errors.
Self-reference is valid only for named comptime parameters. Runtime parameter names are not available while resolving parameter types, and anonymous impl parameters do not introduce a source-visible type name that can be referenced from their own constraint:
fn bad(comptime T: Add(U, T), comptime U: Type) void
fn bad_value(n: usize, value: [n]u8) void
fn bad_anonymous(value: *const impl Add(Self, Self)) void
Public self-constrained parameter annotations are public signature surface. Every source-level helper or constraint name used in the annotation must be visible from the public API, even when the evaluated constraint normalizes to public contract identities.
For function parameters, V1 also supports anonymous static parameters over constraints:
fn sum(xs: *const impl Sequence(f32)) f32
fn draw(p: *const impl satisfies(.{ x: f32, y: f32 })) void
This introduces an anonymous concrete type parameter satisfying the constraint and specializes the function for that concrete type. The source signature still has the written parameter only; the anonymous concrete type is not source-visible and cannot be named in the function body. This form is valid only in function declaration parameter position in V1. *const impl Constraint is not a standalone concrete runtime type and cannot be stored in fields, locals, or type aliases.
Future meta-constraints may allow APIs to accept specific kinds of constraint Type values, such as a Contract constraint that accepts semantic contract type values:
fn accepts_contract(comptime C: Contract) void // future direction
Intersections compose constraints directly:
fn process(comptime S: Sequence(f32) & RealtimeSafe, xs: *const S) void
An intersection constraint accepts concrete types satisfying all components.
General type unions and constraint unions are deferred from V1. In V1, | is used only for closed enum unions and error-set unions. See Enums and Error Types.
Numeric Types¶
Primitive numeric types, comptime_int, and comptime_float are built-in type families. Numeric coercion and explicit conversion rules are documented in Numeric Types.
Error sets are type values constructed with error { ... }. Error is the prelude top error-set type value:
const ReadError = error {
FileNotFound,
}
fn read(path: Path) []u8!Error
See Error Types for the error-set model.
Optional Types¶
?T is the optional type form. null is the absence value, and optional values coerce to bool by testing presence.
See Optional Types for type-level optional rules.
See Optional Bindings for conditional payload binding, same-name if sugar, field optional behavior, and optional binding ownership.
Unions¶
General type unions are deferred from V1. V1 supports | only for enum unions and error-set unions. Pattern matching syntax is also deferred.
Intersections¶
const HotAllocator: Type = Allocator & RealtimeSafe
Intersections express compile-time composition. They must not imply hidden runtime layout or dispatch.
Generics¶
Generics are explicit comptime parameters:
fn first(comptime T: Type, items: []T) T
Unknown type names are errors, not implicit generic parameters. Typos should not silently create generic APIs.
Const generics use the same mechanism:
fn dot(comptime T: Type, comptime N: usize, a: [N]T, b: [N]T) T
Constraints¶
Structural constraints are ordinary comptime Type values:
const PositionLike: Type = satisfies(.{
x: f32,
y: f32,
})
satisfies(...) is the canonical prelude-defined constructor for read-only structural constraint Type values. The predicate form is a method on a concrete Type:
const PositionLike: Type = satisfies(.{ x: f32, y: f32 }) // builds a constraint Type
Point.satisfies(PositionLike) // checks a concrete Type, returns Type.Predicate
Use satisfies(...) for constructing a constraint and T.satisfies(Shape) for testing a concrete type against one.
Plain satisfies(...) field requirements are read-only shape requirements. A const field and a mutable field both satisfy a read field requirement.
Mutable field requirements use a separate prelude-defined constructor:
const MutablePosition: Type = mutable_fields(.{
x: f32,
y: f32,
})
mutable_fields(...) checks source-visible field-like members that can be assigned through ordinary field assignment. A mutable field matches both satisfies(...) and mutable_fields(...). A const field matches satisfies(...) but not mutable_fields(...). Mutable compiler-provided fields, such as slice descriptor .len, may match. Arrays expose no fields in V1; array length is type metadata and is surfaced through reflection and Sequence(T).
Behavioral shapes use the same idea:
const Reader: Type = satisfies(.{
read: fn(self: *Self, buf: []u8) usize!
})
Structural constraints are compile-time only. They do not create runtime interfaces, layout compatibility, vtables, reflection objects, or dynamic dispatch.
Structural constraints are minimum visible shapes. A concrete type may have extra fields or members and still satisfy the constraint. Field requirements are name-based and use normal assignability to the required field type.
The compiler should not special-case a function named satisfies. Instead, compiler-provided Type and reflection APIs should be able to construct structural constraint Type values with inspectable shape metadata. The prelude satisfies(...) declaration may perform ordinary comptime validation and setup, but sema consumes the produced Type value and metadata.
Type Reflection¶
Type values expose comptime-only reflection APIs for fields, layout, function signatures, structural satisfaction, semantic conformance, source metadata, and narrowing-aware predicates.
See Type Reflection.
See Predicate Reflection for Type.Predicate and fact-bearing conformance or satisfaction checks.
Naming Convention¶
Type-like names are capitalized. Comptime functions and prelude-defined constructors use lowercase names. See Naming Conventions.
See Reference and Mutability for value, pointer, slice, mutability, and ownership semantics.
See Numeric Types for primitive numeric coercions and explicit conversions.
See Optional Types for ?T, null, and optional boolean coercion.
See Type Reflection for the comptime Type API.
See Semantic Contracts for explicit semantic contracts.
See Arrays, Slices, Ranges, and Indexing for fixed arrays, slice descriptors, ranges, indexing, and contract-shaped iteration.