Proposal: Reflection API

Status: Approved Author: Ori Language Team Created: 2026-01-31 Approved: 2026-01-31 Affects: Compiler (type system, derive macros), stdlib, spec

Summary

This proposal introduces a reflection API for Ori that enables runtime type introspection while maintaining the language’s safety guarantees. The design centers on an opt-in Reflect trait with static metadata tables, an Unknown type for type-erased values with safe downcasting, and a TypeInfo structure providing comprehensive type metadata. The primary use case is generic serialization/deserialization (JSON, TOML, etc.).

Motivation

Problem Statement

Currently, Ori lacks the ability to inspect types at runtime. This prevents implementation of:

  1. Generic Serialization — Cannot write a single to_json() function that works on any struct
  2. Debug Formatters — Custom debug output requires manual implementation for each type
  3. ORM/Database Mapping — Cannot automatically map structs to database rows
  4. Configuration Loading — Cannot populate structs from config files generically
  5. Validation Frameworks — Cannot apply validation rules based on field names/types
  6. Testing Utilities — Cannot implement property-based testing or fuzzing generically

Design Goals

  1. Opt-in — Types must explicitly derive Reflect; no implicit metadata generation
  2. Zero-cost abstraction — Non-reflecting types pay nothing; reflecting types pay minimal static storage
  3. Read-only — No mutable reflection; values cannot be modified through reflection
  4. Type-safe — All dynamic operations return Option or Result; no unsafe downcasts
  5. ARC-compatible — Works with Ori’s reference-counted memory model
  6. Testable — Reflection operations are pure and deterministic

Non-Goals

  1. Method Reflection — Calling methods dynamically (future proposal)
  2. Mutable Reflection — Modifying values through reflection
  3. Dynamic Type Creation — Creating new types at runtime
  4. Proxy/Interception — AOP-style method interception
  5. Private Field Access — Only public fields are visible to reflection

Design

1. The Reflect Trait

// Defined in std.reflect

trait Reflect {
    // Get static type information
    @type_info (self) -> TypeInfo

    // Field access by index (for iteration)
    @field_count (self) -> int

    // Get field value as Unknown by index (0-based)
    @field_by_index (self, index: int) -> Option<Unknown>

    // Get field value as Unknown by name
    @field_by_name (self, name: str) -> Option<Unknown>

    // Get current variant info (for enums; returns None for structs)
    @current_variant (self) -> Option<VariantInfo>
}

Derivation:

#derive(Reflect)
type Person = {
    name: str,
    age: int,
    email: Option<str>,
}

The derive macro generates implementations that:

  • Return static TypeInfo (interned once per type)
  • Provide O(1) field access by index
  • Provide O(1) field access by name (via static hash map)

Constraints:

  • All fields must also implement Reflect
  • Private fields (::-prefixed) are excluded from reflection
  • Generic types derive conditionally: Person<T> reflects when T: Reflect

2. The TypeInfo Structure

// Comprehensive type metadata

type TypeInfo = {
    name: str,           // Simple type name: "Person"
    module: str,         // Full module path: "myapp.models"
    kind: TypeKind,      // Category of type
    fields: [FieldInfo], // For structs (empty for others)
    variants: [VariantInfo], // For enums (empty for others)
    type_params: [str],  // Generic parameter names: ["T", "E"]
}

type TypeKind =
    | Struct
    | Enum
    | Primitive
    | List
    | Map
    | Tuple
    | Function
    | Trait

type FieldInfo = {
    name: str,
    type_name: str,      // Type as string: "Option<str>"
    index: int,          // 0-based position
    is_optional: bool,   // True if type is Option<T>
}

type VariantInfo = {
    name: str,           // Variant name: "Some", "None"
    index: int,          // Variant index (for matching)
    fields: [FieldInfo], // Payload fields (empty for unit variants)
}

Static Metadata:

TypeInfo is generated at compile time and stored in static tables. Each reflecting type has exactly one TypeInfo instance, referenced by all values of that type.

// Accessing type info
let person = Person { name: "Alice", age: 30, email: None }
let info = person.type_info()

assert_eq(actual: info.name, expected: "Person")
assert_eq(actual: info.kind, expected: Struct)
assert_eq(actual: len(collection: info.fields), expected: 3)
assert_eq(actual: info.fields[0].name, expected: "name")

3. The Unknown Type

The Unknown type is a type-erased container with safe downcasting. Unlike TypeScript’s any or unknown, Ori’s Unknown requires explicit downcasting before any operations — you cannot call methods or access fields on an Unknown value directly.

// Type-erased container with safe downcasting

type Unknown = {
    // Private: holds erased value and type info
    ::value: ErasedValue,
    ::type_info: TypeInfo,
}

impl Unknown {
    // Create an Unknown from a reflecting value
    @new<T: Reflect> (value: T) -> Unknown

    // Get the type name
    @type_name (self) -> str

    // Get full type info
    @type_info (self) -> TypeInfo

    // Check if this Unknown holds a value of type T
    @is<T: Reflect> (self) -> bool

    // Attempt to downcast to concrete type T
    @downcast<T: Reflect> (self) -> Option<T>

    // Downcast or panic with message
    @unwrap<T: Reflect> (self) -> T

    // Downcast or return default
    @unwrap_or<T: Reflect> (self, default: T) -> T
}

Note: ErasedValue is an internal compiler type representing the type-erased value storage. It is not accessible to user code.

Usage:

let value: Unknown = Unknown.new(value: 42)

assert(condition: value.is<int>())
assert_eq(actual: value.type_name(), expected: "int")

// Safe downcast
match value.downcast<int>() {
    Some(n) -> print(msg: `Got integer: {n}`),
    None -> print(msg: "Not an integer"),
}

// Or with unwrap
let n = value.unwrap<int>()  // Panics if not int

Primitive Implementations:

All primitives (int, float, str, bool, char, byte) implement Reflect with their respective TypeInfo.

4. Standard Library Reflect Implementations

The following types implement Reflect:

Primitives:

  • int, float, str, bool, char, byte, void

Collections:

  • [T] where T: Reflect
  • {K: V} where K: Reflect, V: Reflect
  • Set<T> where T: Reflect

Option and Result:

  • Option<T> where T: Reflect
  • Result<T, E> where T: Reflect, E: Reflect

Tuples:

  • (), (T), (T, U), etc. where all elements implement Reflect

Special Types:

  • Duration, Size

5. Derived Reflect Implementation

For a struct:

#derive(Reflect)
type Point = {
    x: int,
    y: int,
}

The compiler generates:

impl Point: Reflect {
    @type_info (self) -> TypeInfo = $POINT_TYPE_INFO

    @field_count (self) -> int = 2

    @field_by_index (self, index: int) -> Option<Unknown> = match index {
        0 -> Some(Unknown.new(value: self.x)),
        1 -> Some(Unknown.new(value: self.y)),
        _ -> None,
    }

    @field_by_name (self, name: str) -> Option<Unknown> = match name {
        "x" -> Some(Unknown.new(value: self.x)),
        "y" -> Some(Unknown.new(value: self.y)),
        _ -> None,
    }

    @current_variant (self) -> Option<VariantInfo> = None
}

Where $POINT_TYPE_INFO is a compile-time constant:

let $POINT_TYPE_INFO = TypeInfo {
    name: "Point",
    module: "myapp.geometry",
    kind: Struct,
    fields: [
        FieldInfo { name: "x", type_name: "int", index: 0, is_optional: false },
        FieldInfo { name: "y", type_name: "int", index: 1, is_optional: false },
    ],
    variants: [],
    type_params: [],
}

For an enum:

#derive(Reflect)
type Shape =
    | Circle(radius: float)
    | Rectangle(width: float, height: float)
    | Point

The compiler generates similar code with variant matching:

impl Shape: Reflect {
    @type_info (self) -> TypeInfo = $SHAPE_TYPE_INFO

    @field_count (self) -> int = match self {
        Circle(_) -> 1,
        Rectangle(_, _) -> 2,
        Point -> 0,
    }

    @field_by_index (self, index: int) -> Option<Unknown> = match self {
        Circle(radius) if index == 0 -> Some(Unknown.new(value: radius)),
        Rectangle(width, _) if index == 0 -> Some(Unknown.new(value: width)),
        Rectangle(_, height) if index == 1 -> Some(Unknown.new(value: height)),
        _ -> None,
    }

    @field_by_name (self, name: str) -> Option<Unknown> = match self {
        Circle(radius) if name == "radius" -> Some(Unknown.new(value: radius)),
        Rectangle(width, _) if name == "width" -> Some(Unknown.new(value: width)),
        Rectangle(_, height) if name == "height" -> Some(Unknown.new(value: height)),
        _ -> None,
    }

    @current_variant (self) -> Option<VariantInfo> = Some(match self {
        Circle(_) -> $SHAPE_CIRCLE_VARIANT,
        Rectangle(_, _) -> $SHAPE_RECTANGLE_VARIANT,
        Point -> $SHAPE_POINT_VARIANT,
    })
}

6. Reflection for Generic Types

Generic types derive Reflect conditionally:

#derive(Reflect)
type Container<T> = {
    items: [T],
    count: int,
}

// Reflects when T: Reflect
impl<T: Reflect> Container<T>: Reflect {
    @type_info (self) -> TypeInfo = {
        let base = $CONTAINER_TYPE_INFO
        TypeInfo {
            ...base
            type_params: [T.type_info().name]
        }
    }

    // ... other methods

    @current_variant (self) -> Option<VariantInfo> = None
}

7. Type Comparison and Identity

// Types can be compared by TypeInfo
@types_equal<A: Reflect, B: Reflect> () -> bool = {
    let a_info = A.type_info()
    let b_info = B.type_info()
    a_info.name == b_info.name && a_info.module == b_info.module
}

// Get type identity from a value
@type_id_of<T: Reflect> (value: T) -> int = {
    let info = value.type_info()
    hash_combine(seed: info.name.hash(), value: info.module.hash())
}

8. Iteration Over Fields

// Helper for iterating all fields of a reflecting value
extend<T: Reflect> T {
    @fields (self) -> impl Iterator where Item == (str, Unknown) = {
        let count = self.field_count()
        let info = self.type_info()
        (0..count).iter()
            .filter_map(transform: i -> {
                let field_info = info.fields[i]
                self.field_by_index(index: i)
                    .map(transform: v -> (field_info.name, v))
            })
    }
}

Usage:

let person = Person { name: "Alice", age: 30, email: Some("alice@example.com") }

for (name, value) in person.fields() do
    print(msg: `{name}: {value.type_name()}`)

// Output:
// name: str
// age: int
// email: Option<str>

Object Safety

The Reflect trait methods return concrete types (TypeInfo, int, Option<Unknown>, Option<VariantInfo>), making them individually object-safe. However, Reflect is not practically usable as a trait object because:

  1. Deriving requires the concrete type at compile time
  2. Unknown.new<T: Reflect>() requires the concrete type T

For dynamic dispatch over reflecting types, use Unknown which provides runtime type information via type_info().

Examples

Example 1: Generic JSON Serialization

use std.json { Json, JsonValue }
use std.reflect { Reflect, TypeKind }

// Generic to_json for any reflecting type
@to_json_generic<T: Reflect> (value: T) -> JsonValue = {
    let info = value.type_info()
    match info.kind {
        Primitive -> to_json_primitive(value:)
        Struct -> to_json_struct(value:)
        Enum -> to_json_enum(value:)
        List -> to_json_list(value:)
        Map -> to_json_map(value:)
        _ -> JsonValue.Null
    }
}

@to_json_struct<T: Reflect> (value: T) -> JsonValue = {
    let pairs = for (name, field_value) in value.fields()
        yield (name, to_json_unknown(value: field_value))
    JsonValue.Object(pairs.collect())
}

@to_json_unknown (value: Unknown) -> JsonValue = match value.type_info().kind {
    Primitive -> match value.type_name() {
        "int" -> JsonValue.Number(value.unwrap<int>() as float),
        "float" -> JsonValue.Number(value.unwrap<float>()),
        "str" -> JsonValue.String(value.unwrap<str>()),
        "bool" -> JsonValue.Bool(value.unwrap<bool>()),
        _ -> JsonValue.Null,
    },
    Struct -> to_json_struct(value: value.unwrap<_>()),
    List -> to_json_list(value: value.unwrap<_>()),
    _ -> JsonValue.Null,
}

Example 2: Generic Debug Printer

use std.reflect { Reflect, TypeKind }

@debug_print<T: Reflect> (value: T, indent: int = 0) -> str = {
    let info = value.type_info()
    let prefix = " ".repeat(count: indent * 2)
    match info.kind {
        Struct -> {
            let fields_str = for (name, field) in value.fields()
                yield `{prefix}  {name}: {debug_print(value: field, indent: indent + 1)}`
            `{info.name} {{\n{fields_str.join(separator: ",\n")}\n{prefix}}}`
        }
        Enum -> {
            let variant = value.current_variant().unwrap().name
            if value.field_count() == 0 then variant
            else {
                let fields_str = for (_, field) in value.fields()
                    yield debug_print(value: field, indent: indent + 1)
                `{variant}({fields_str.join(separator: ", ")})`
            }
        }
        _ -> value.to_str()
    }
}

Example 3: Struct Validation

use std.reflect { Reflect, TypeKind }

type ValidationError = {
    field: str,
    message: str,
}

@validate_not_empty<T: Reflect> (value: T) -> [ValidationError] = {
    let info = value.type_info()
    for field_info in info.fields if field_info.type_name == "str"
        let field_value = value.field_by_name(name: field_info.name)
        if field_value.is_some() && is_empty_str(value: field_value.unwrap())
            yield ValidationError {
                field: field_info.name
                message: "cannot be empty"
            }
}

@is_empty_str (value: Unknown) -> bool = match value.downcast<str>() {
    Some(s) -> is_empty(collection: s),
    None -> false,
}

Example 4: Generic Clone via Reflection

// Deep clone any reflecting type
@deep_clone<T: Reflect + Clone> (value: T) -> T = value.clone()

// Reflection-based clone for types that can't derive Clone
@reflect_clone<T: Reflect> (value: T) -> T = {
    let info = value.type_info()
    match info.kind {
        Struct -> reflect_clone_struct(value:)
        Enum -> reflect_clone_enum(value:)
        _ -> value,  // Primitives are Copy
    }
}

Example 5: Configuration Loading

use std.reflect { Reflect, TypeInfo }
use std.toml { TomlValue }

@load_config<T: Reflect + Default> (toml: TomlValue) -> Result<T, str> = {
    let default = T.default()
    let info = default.type_info()
    populate_from_toml(target: default, source: toml, info:)
}

@populate_from_toml<T: Reflect> (target: T, source: TomlValue, info: TypeInfo) -> Result<T, str> =
    // Build new value from TOML using reflection
    for field_info in info.fields do
        let toml_key = field_info.name
        match source.get(key: toml_key) {
            Some(toml_value) -> set_field_from_toml(target:, field: field_info, value: toml_value)?,
            None if field_info.is_optional -> continue,
            None -> Err(`missing required field: {toml_key}`),
        }
    Ok(target)

Integration with Ori Design Pillars

Mandatory Verification

All reflection operations are testable:

@test_person_type_info tests @type_info () -> void = {
    let person = Person { name: "Test", age: 0, email: None }
    let info = person.type_info()
    assert_eq(actual: info.name, expected: "Person")
    assert_eq(actual: info.fields[0].name, expected: "name")
}

Explicit Effects

Reflection operations are pure—they don’t require capabilities. The Reflect trait is a compile-time marker that enables runtime introspection without side effects.

ARC-Safe

  • Unknown owns its value (reference counted)
  • No shared mutable references through reflection
  • Field access returns copies wrapped in Unknown
  • No raw pointers or unsafe memory access

Opt-in Design

Types must explicitly derive Reflect:

// This type does NOT reflect (no metadata generated)
type Secret = {
    password: str,
}

// This type DOES reflect
#derive(Reflect)
type PublicInfo = {
    username: str,
}

This ensures:

  1. No hidden code size cost
  2. Sensitive types can avoid reflection
  3. Explicit contract about type capabilities

Error Handling

Derive Errors

error[E0450]: cannot derive `Reflect` for `Container`
  --> src/types.ori:10:1
   |
10 | #derive(Reflect)
   | ^^^^^^^^^^^^^^^^
   |
   = note: field `secret` has type `Password` which does not implement `Reflect`
   = help: either derive `Reflect` for `Password` or remove it from `Container`

Downcast Errors

let value: Unknown = Unknown.new(value: "hello")
let result = value.downcast<int>()  // Returns None

// unwrap panics with clear message
let n = value.unwrap<int>()
// panic: type mismatch: expected `int`, found `str`

Field Access Errors

let person = Person { name: "Alice", age: 30, email: None }

// Invalid field name returns None
let bad = person.field_by_name(name: "nonexistent")  // None

// Invalid index returns None
let also_bad = person.field_by_index(index: 100)  // None

Performance Considerations

Static Metadata

All TypeInfo is generated at compile time and stored in read-only static memory:

// Conceptual layout (actual implementation is Rust)
static PERSON_TYPE_INFO: TypeInfo = TypeInfo { ... };
  • No per-instance overhead
  • No runtime metadata construction
  • O(1) access to type information

Field Access

  • By index: O(1) via match dispatch
  • By name: O(1) via static hash map (generated at compile time)

Unknown Boxing

Creating Unknown requires:

  • One allocation for the erased value (reference counted)
  • One pointer copy for TypeInfo reference

This is comparable to trait object allocation in other languages.

Code Size

Each reflecting type adds:

  • One static TypeInfo (tens to hundreds of bytes)
  • Match-based field accessors (linear in field count)
  • No vtables or dynamic dispatch beyond Unknown

Opt-Out for Performance

Performance-critical code can avoid reflection:

// No Reflect derive = zero reflection cost
type HotPath = {
    data: [float],
    count: int,
}

Dependencies

This proposal depends on:

  1. Phase 03 (Traits) — Trait definitions and implementations
  2. Phase 05 (Type Declarations) — Struct and enum syntax
  3. Phase 07 (Derive Macros)#derive() infrastructure
  4. Phase 11 (Generics) — Generic type support

Future Extensions

The following are explicitly deferred to future proposals:

Method Reflection

// Future: reflect on methods
trait MethodReflect: Reflect {
    @method_count (self) -> int
    @method_by_name (self, name: str) -> Option<MethodInfo>
    @call (self, method: str, args: [Unknown]) -> Result<Unknown, str>
}

Mutable Reflection

// Future: modify values through reflection
trait ReflectMut: Reflect {
    @set_field_by_name (self, name: str, value: Unknown) -> Result<void, str>
}

Dynamic Type Creation

// Future: create types at runtime
@create_struct (name: str, fields: [(str, TypeInfo)]) -> TypeInfo

Deferred Decisions

The following are explicitly deferred to future proposals:

  1. Trait Object Reflection — Whether trait objects (Trait as a type) should implement Reflect and store TypeInfo in the vtable
  2. Function Reflection — TypeInfo for function parameter/return types
  3. Const Generics — How [T, max N] reflects its capacity
  4. Derive Attributes — Support for #reflect(skip) or #reflect(rename: ...)

See Future Extensions for method reflection and mutable reflection.

Spec Changes Required

New Spec Section: 27-reflection.md

Add comprehensive section covering:

  • Reflect trait definition
  • TypeInfo structure
  • Unknown type
  • Derive semantics
  • Standard implementations

Update: 08-declarations.md

Add Reflect to the list of derivable traits.

Update: Prelude

Add to prelude:

  • Reflect trait
  • TypeInfo, TypeKind, FieldInfo, VariantInfo types
  • Unknown type

Update: std.reflect Module

New module with:

  • Reflect trait and related types
  • Helper functions for reflection operations
  • Extension methods for reflecting types

Summary Table

FeatureDesign Decision
Opt-inTypes must derive Reflect explicitly
Metadata StorageStatic, compile-time generated
Field AccessO(1) by index and name
Type SafetyAll operations return Option or Result
Unknown TypeReference-counted, type-erased container
Mutable ReflectionNot supported (deferred)
Method ReflectionNot supported (deferred)
Private FieldsNot visible to reflection
Generic TypesConditional derivation when bounds satisfied
PrimitivesAll implement Reflect by default
CollectionsImplement when element types reflect
PerformanceZero cost for non-reflecting types
Code SizeStatic metadata per reflecting type
Variant Access@current_variant returns current enum variant

Conclusion

This proposal provides a minimal but complete reflection API for Ori that enables important use cases like generic serialization while maintaining the language’s safety guarantees. The opt-in design ensures zero cost for types that don’t need reflection, while the static metadata approach keeps runtime overhead minimal.

The focus on read-only data reflection provides a solid foundation that can be extended in future proposals to include method reflection and mutable operations as needed.