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 primitive comptime type of type handles. A value whose static type is type denotes a type and is valid wherever source expects a type expression.
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 handle.
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 handle; a generic contract is a comptime function returning a type handle.
type handles may denote 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 handle, not separate source-level kinds:
T.type.is_concrete()
T.type.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 TypeDef APIs are responsible for constructing and inspecting these handles. type handles are comptime-only. Reflection and metadata operations live on the explicit TypeDef definition view returned by T.type or TypeDef.of(T), not on arbitrary type handles.
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.
Denotation¶
A type handle denotes the type-table entry it names. In a type-expression position, the compiler consumes the denoted type directly:
const T: type = i32
var value: T = 0
This is denotation, not pointer-style unwrapping. V1 has no explicit type-handle unwrap operator. The special behavior of type handles is limited to type-expression positions, type-namespace lookup, the .type definition-view bridge, and APIs that explicitly accept type handles.
T.type creates the explicit compiler-owned TypeDef view for inspecting or mutating metadata about the denoted type. It is equivalent to TypeDef.of(T). V1 does not implicitly coerce type handles to TypeDef; source must spell either T.type or TypeDef.of(T).
value.type is not V1 runtime type identity. The .type bridge is valid only when the left operand has static type type; runtime type identity and downcasts are deferred.
Type Namespaces¶
When the left operand of dot lookup has type type, T.member resolves in the namespace or static surface of the type denoted by T. It does not resolve TypeDef reflection helpers.
The reserved pseudo-member type is the only exception. T.type returns the TypeDef view for the denoted type. It cannot be declared, shadowed, or forwarded by a denoted type namespace because type is a reserved word.
This rule covers all V1 type-owned source members:
- enum cases and error-set cases;
- unambiguous enum-union and closed error-set-union cases forwarded from member types;
- struct inherent functions and future static members;
- semantic contract operations and default methods used for qualified contract calls;
- source-visible members of ordinary type declarations and compiler-provided type definitions.
For example, Ordering.less resolves the less case in the Ordering enum type namespace, and Buffer.create resolves an inherent function in the Buffer type namespace. If a user-defined type has a static member named fields, T.fields refers to that member when T denotes the user type; reflection is available through T.type.fields().
Instance fields are not static namespace members. Point.x is not a field projection without an instance; use p.x for value field access and Point.type.fields() for field reflection.
If T.member has no visible match in the denoted type namespace, sema reports an ordinary missing static member or case on the denoted type, except for the reserved T.type bridge. V1 does not use a separate type-namespace operator.
Type Relationships¶
Catalyst does not use an implicit is-a inheritance ladder for type handles, contracts, reflection, namespaces, or erased dynamic values. Type relationships are explicit and category-specific:
T: typemeansTis a comptime value whose value is a type handle.T.membermeans lookup in the namespace or static surface of the type denoted byT.T.typeorTypeDef.of(T)means a compiler-owned definition view for the type handleT.T.type.satisfies(Shape)means a concrete type visibly matches a structural constraint.T.type.implements(C)means a concrete type visibly has explicit semantic conformance to a contract.dyn Cmeans a concrete erased type for a dyn-safe semantic contract surface.- ordinary value coercion, optional presence, pointer dereference, and dynamic erasure are separate relationships with their own rules.
These relationships may interact, but none of them makes one type handle inherit members, reflection methods, contract operations, or namespace entries from another. T.type is an explicit bridge to a different value, not inherited behavior on T. When a page says a value "is" a type, constraint, contract, dyn value, namespace, or reflection object, it should name the specific relationship rather than imply inheritance.
Generic parameter annotations accept any type handle 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 handle 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 handles, 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 handles 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 handles:
const PositionLike: type = satisfies(.{
x: f32,
y: f32,
})
satisfies(...) is the canonical prelude-defined constructor for read-only structural constraint type handles. The predicate form checks a concrete type through its TypeDef definition view:
const PositionLike: type = satisfies(.{ x: f32, y: f32 }) // builds a constraint type
Point.type.satisfies(PositionLike) // checks a concrete type, returns TypeDef.Predicate
Use satisfies(...) for constructing a constraint and T.type.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 TypeDef APIs should be able to construct structural constraint type handles with inspectable shape metadata. The prelude satisfies(...) declaration may perform ordinary comptime validation and setup, but sema consumes the produced type handle and metadata.
Type Reflection¶
TypeDef exposes comptime-only reflection APIs for fields, layout, function signatures, structural satisfaction, semantic conformance, source metadata, and narrowing-aware predicates. Reflection APIs are methods on TypeDef, not receiver methods on arbitrary type handles. Reflection-heavy code may bind const def = T.type and call receiver-style methods on that definition view. TypeDef.of(T) remains the canonical constructor form when a functional spelling is clearer.
See Type Reflection.
See Predicate Reflection for TypeDef.Predicate and fact-bearing conformance or satisfaction checks.
Naming Convention¶
Type-like names are capitalized except for the primitive type handle type and other primitive/keyword-adjacent forms. 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 TypeDef 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.