Skip to main content

Type Generator Functions

In Osta, types are first-class citizens during compilation, meaning they can be manipulated like any other entity within compile-time contexts. This design replaces traditional generics with type generator functions—compile-time functions that produce types based on arbitrary arguments. For generic compound types, Osta provides a synthetic #type function in the type’s namespace, serving as a default type constructor. This document explains type generator functions, their representation, and their usage in Osta, covering both standalone functions and those tied to generic structs.

What’s a Generic Type?

A generic type in Osta is a type parameterized by other types or values, enabling reusable, type-safe code. At its core, these types are the output of type generator functions, which are compile-time functions that produce types. For example, a Map type parameterized by key and value types (e.g., Map<i32, f64>) is generated by a compile-time function that constructs a struct tailored to those parameters. This approach leverages Osta’s type system, where types are first-class during compilation, allowing flexible type constructions.

Generic types appear in code with familiar syntax (e.g., Map<K, V>), but internally, they result from invoking type generator functions, often with syntactic sugar provided by the compiler. These types integrate with Osta’s features, such as type inference and lifetime management, and are used in variable declarations, function signatures, or further compile-time computations.

How Osta’s Compiler Represents Generic Types

Osta’s compiler represents generic types as the output of compile-time functions, executed during compilation to produce concrete type definitions. When a generic type like Map<i32, f64> is used, the compiler invokes the associated type generator function (e.g., Map::#type(i32, f64)) to construct the type. This function defines the type’s structure, such as a struct with fields tailored to the provided parameters.

For compound types (e.g., structs, tagged unions), the compiler automatically provides a synthetic #type function in the type’s namespace unless overridden. This function acts as a default constructor, generating the type based on the provided arguments. The compiler translates syntactic sugar (e.g., Map<K, V>) into calls to these functions, ensuring type safety and consistency.

Caching of Type Constructions

Osta’s compiler does not currently cache type constructions. Each use of a generic type, even with identical parameters, re-evaluates the type generator function, potentially increasing compile times for complex types or frequent instantiations. Future versions may implement caching to improve performance.

To mitigate this, manual monomorphization is highly recommended. Create a monomorphization file to predefine specific type instances, like so:

// Predefine a Map type for i32 keys and f64 values
pub const IntFloatMap: type = Map<i32, f64>;
// Equivalent to Map::#type(i32, f64)

Creating a Compile-Time Function to Generate a Type

A type generator function is a compile-time function with a name starting with # (e.g., #fixedSizeArray), which treats types as first-class citizens, allowing them to be manipulated like other values. These functions can take arbitrary arguments, such as types, constants, or other values, and return a type. They can be defined standalone or within a type’s namespace and are executed at compile-time to produce type definitions.

Syntax:

pub fn #functionName(arg1: T1, arg2: T2, ...) -> type {
// Return a type definition
}

Example:

pub fn #fixedSizeArray(T: type, size: usize) -> type {
[T; size]
}

fn main() {
let arr: #fixedSizeArray(i32, 5) = [1, 2, 3, 4, 5];
print(arr[0]); // Outputs: 1
}

In this example, #fixedSizeArray takes a type T and a usize constant size, returning a fixed-size array type [T; size]. The function is standalone, demonstrating that compile-time functions can accept non-type arguments, such as usize, for flexible type generation. Types are treated as first-class within the function, enabling their manipulation alongside other arguments.

Creating a Generic Struct

A generic struct is a compound type parameterized by other types or values, defined with a struct declaration that includes type parameters. The compiler automatically provides a synthetic #type function in the struct’s namespace to generate the type based on these parameters, unless a custom implementation is provided.

Example:

struct Map<K: type, V: type> {
keys: *K,
values: *V,
size: usize,
}

impl<K: type, V: type> Map<K, V> {
pub fn new() -> Map<K, V> {
Map<K, V> { keys: 0, values: 0, size: 0 }
}
}

fn main() {
let map: Map<i32, f64> = Map<i32, f64>::new();
print(map.size); // Outputs: 0
}

Here, Map<K, V> is a generic struct parameterized by K and V. The compiler provides a default Map::#type(K: type, V: type) function that constructs the struct type. The Map<i32, f64> syntax is sugar for invoking Map::#type(i32, f64), and the new method creates an instance of the generated type.

Adding Custom Type Constructors

Developers can define custom type constructors by implementing a #type function in the struct’s impl block, overriding the default synthetic #type function. This allows tailored type generation, such as adding fields or enforcing constraints, with types treated as first-class citizens within the function.

Example:

struct ConstrainedArray<T: type, S: usize, Min: usize, Max: usize> {
data: [T; S],
size: usize,
}

impl ConstrainedArray {
pub fn #type(T: type, S: usize, Min: usize, Max: usize) -> type {
if S < Min || S > Max {
std::comptime::#error("Size must be within the specified bounds.");
}
struct {
data: [T; S],
size: usize,
}
}
}

impl<T: type, S: usize, Min: usize, Max: usize> ConstrainedArray<T, S, Min, Max> {
pub fn new(data: [T; S]) -> ConstrainedArray<T, S, Min, Max> {
ConstrainedArray<T, S, Min, Max> { data, size: S }
}
}

fn main() {
let arr: ConstrainedArray<i32, 2, 0, 5> = ConstrainedArray<i32, 2, 0, 5>::new([0, 1]);
print(arr.size); // Outputs: 2
}

In this example, the custom ConstrainedArray::#type function adds constraints on the size of the array, showing how developers can extend the default type construction while manipulating types as first-class entities.

Forbidding the Default Type Constructor

To prevent the use of the synthetic #type function, developers can define a custom #type function that triggers a compile-time error when invoked with unsupported arguments. This ensures that only specific, custom type constructors are used.

Example:

struct Map<K: type, V: type> {
keys: *K,
values: *V,
size: usize,
}

impl Map {
pub fn #type(K: type, V: type) -> type {
std::comptime::#error("Default #type constructor is forbidden. Use Map::#type(W, K, V) instead.");
}

pub fn #type(W: fn(T: type) -> type, K: type, V: type) -> type {
struct {
keys: *W<K>,
values: *W<V>,
size: usize,
}
}
}

struct Wrapper<T: type> {
value: T,
}

impl<K: type, V: type> Map<Wrapper<K>, Wrapper<V>> {
pub fn new() -> Map<Wrapper<K>, Wrapper<V>> {
Map<Wrapper<K>, Wrapper<V>> { keys: 0, values: 0, size: 0 }
}
}

fn main() {
// let map: Map<i32, f64> = Map<i32, f64>::new(); // Compile error: Default #type forbidden
let map: Map<Wrapper<i32>, Wrapper<f64>> = Map<Wrapper::#type, i32, f64>::new();
print(map.size); // Outputs: 0
}

Here, the default Map::#type function is overridden to throw a compile-time error, forcing the use of Map::#customType, which constructs a Map with wrapped types.