Back to flin
flin

Introspection and Reflection at Runtime

How FLIN's introspection system lets programs examine their own types, fields, and structures at runtime -- enabling dynamic forms, serialization, and debugging without sacrificing type safety.

Thales & Claude | March 25, 2026 10 min flin
flinintrospectionreflectionruntime

A program that cannot examine itself is a program that cannot adapt. When you build a form generator that creates input fields from an entity definition, you need to know what fields the entity has at runtime. When you build a serializer that converts any value to JSON, you need to inspect the value's type and structure dynamically. When you build a debugger, you need to display the contents of any variable without knowing its type in advance.

This is introspection -- the ability of a program to examine its own types and structures at runtime. In Sessions 178 through 181, we built FLIN's introspection system: a set of built-in functions that let code inspect types, enumerate fields, check properties, and navigate structures dynamically. All without sacrificing the type safety that makes FLIN reliable.

The Tension: Static Types vs. Dynamic Inspection

FLIN is a statically typed language. The type checker knows, at compile time, that user.name is a text value and user.age is an int. This is what makes FLIN safe -- you cannot accidentally call upper on a number or sum on a string.

But static types are compile-time knowledge. At runtime, the VM works with values on a stack. A value is a number, or a string, or a map, or an entity instance. The VM knows which one it is (every value carries a type tag), but it does not know what fields an entity has unless you tell it.

Introspection bridges this gap. It gives runtime code access to the type information that the compiler already knows, enabling patterns that static types alone cannot express.

flinentity User {
    name: text
    email: text where is_email
    age: int where > 0
    role: text = "user"
}

// At runtime, inspect the entity
fields = fields_of(User)
// ["name", "email", "age", "role"]

type_name = type_of(User)
// "entity"

// Inspect an instance
user = User.create(name: "Juste", email: "[email protected]", age: 28)
field_types = field_types_of(user)
// { "name": "text", "email": "text", "age": "int", "role": "text" }

The Core Functions

type_of: What Is This Value?

flintype_of(42)                // "int"
type_of(3.14)              // "float"
type_of("hello")           // "text"
type_of(true)              // "bool"
type_of([1, 2, 3])         // "list"
type_of({ a: 1 })          // "map"
type_of(none)              // "none"
type_of(user)              // "User" (entity type name)

type_of returns a string describing the value's type. For primitive types, it returns the type name ("int", "float", "text", "bool"). For collections, it returns the collection type ("list", "map"). For entity instances, it returns the entity name ("User", "Product", "Order").

This function is essential for debugging and for writing generic code that handles different types:

flinfn format_value(value) {
    match type_of(value) {
        "text" => "\"{value}\""
        "int" | "float" => "{value}"
        "bool" => "{value}"
        "none" => "none"
        "list" => "[{value.map(v => format_value(v)).join(', ')}]"
        "map" => format_map(value)
        _ => "<{type_of(value)}>"
    }
}

Type Checking Predicates

For quick type checks, FLIN provides boolean predicates:

flinis_text("hello")           // true
is_int(42)                 // true
is_float(3.14)             // true
is_bool(true)              // true
is_list([1, 2])            // true
is_map({ a: 1 })           // true
is_none(none)              // true
is_entity(user)            // true

These are more efficient than type_of(value) == "text" because they do not allocate a string for the type name. They compile to a single opcode that checks the value's type tag and pushes a boolean.

fields_of: What Fields Does This Entity Have?

flinfields = fields_of(User)
// ["name", "email", "age", "role"]

// Works on instances too
fields = fields_of(user)
// ["name", "email", "age", "role"]

fields_of returns a list of field names for an entity type or an entity instance. This is the foundation of dynamic form generation -- you can iterate over an entity's fields and create an input for each one without hardcoding the field names.

field_types_of: What Types Do the Fields Have?

flintypes = field_types_of(User)
// { "name": "text", "email": "text", "age": "int", "role": "text" }

field_types_of returns a map from field names to their type names. Combined with fields_of, this gives you complete structural information about any entity at runtime.

has_field: Does This Entity Have a Specific Field?

flinhas_field(user, "name")    // true
has_field(user, "phone")   // false

has_field is a quick check that avoids the overhead of calling fields_of and searching the list. It compiles to a single map lookup in the entity's field table.

get_field and set_field: Dynamic Field Access

flin// Dynamic get
value = get_field(user, "name")
// "Juste"

// Dynamic set
set_field(user, "name", "Thales")

These functions access entity fields by name at runtime, bypassing the static user.name syntax. They are essential for generic code that operates on entities without knowing their type in advance:

flinfn copy_fields(source, target, field_names: [text]) {
    {for field in field_names}
        value = get_field(source, field)
        set_field(target, field, value)
    {/for}
}

Use Case: Dynamic Form Generation

The killer use case for introspection is automatic form generation. Given an entity type, generate a complete form with appropriate input types for each field:

flinentity Product {
    name: text
    description: text
    price: float where > 0
    in_stock: bool = true
    category: text
}

// Generate form fields dynamically
fn render_entity_form(entity_type) {
    fields = fields_of(entity_type)
    types = field_types_of(entity_type)

    <Form submit={handle_submit()}>
        {for field in fields}
            <FormField label={field.title}>
                {if types[field] == "text"}
                    <Input value={form_data[field]}
                           placeholder="Enter {field}" />
                {else if types[field] == "int" or types[field] == "float"}
                    <Input type="number"
                           value={form_data[field]} />
                {else if types[field] == "bool"}
                    <Switch checked={form_data[field]}
                            label={field.title} />
                {/if}
            </FormField>
        {/for}
        <Button type="submit" variant="primary">Save</Button>
    </Form>
}

This function generates a form for any entity. Pass it User, and it creates inputs for name, email, age, and role. Pass it Product, and it creates inputs for name, description, price, in_stock, and category. The form adapts to the entity structure at runtime.

Use Case: Generic Serialization

Another powerful use case is writing serializers that convert any value to a specific format:

flinfn to_csv_row(entity_instance) {
    fields = fields_of(entity_instance)
    values = fields.map(f => {
        value = get_field(entity_instance, f)
        {if is_text(value)}
            "\"{value.replace('"', '""')}\""
        {else if value == none}
            ""
        {else}
            "{value}"
        {/if}
    })
    values.join(",")
}

fn to_csv(entities: list) {
    {if entities.is_empty}
        return ""
    {/if}

    header = fields_of(entities.first).join(",")
    rows = entities.map(e => to_csv_row(e))
    [header].concat(rows).join("\n")
}

// Usage
users = User.all
csv = to_csv(users)
// "name,email,age,role\n\"Juste\",\"[email protected]\",28,\"admin\"\n..."

This CSV serializer works with any entity type. It discovers fields dynamically, handles quoting for text values, and produces a complete CSV string. No entity-specific code. No code generation. Just introspection.

Use Case: Debug Logging

The debug built-in function uses introspection internally to produce detailed output for any value:

flinuser = User.create(name: "Juste", email: "[email protected]", age: 28)
debug(user)
// User {
//   name: "Juste"
//   email: "[email protected]"
//   age: 28
//   role: "user"
// }

Without introspection, debug could only print the value's type tag and memory address. With introspection, it enumerates every field and prints a readable representation. This is invaluable during development -- a single debug(value) call tells you everything about a value's contents.

Implementation: Metadata Tables in the VM

Introspection works because the FLIN compiler embeds metadata about entity types into the bytecode, and the VM maintains a registry of this metadata at runtime.

During compilation, each entity definition produces a metadata entry:

rustpub struct EntityMetadata {
    pub name: String,
    pub fields: Vec<FieldMetadata>,
}

pub struct FieldMetadata {
    pub name: String,
    pub type_name: String,
    pub has_default: bool,
    pub is_optional: bool,
}

The VM stores these in a hash map keyed by entity name:

rustpub struct Vm {
    // ... other fields
    entity_metadata: HashMap<String, EntityMetadata>,
}

impl Vm {
    fn builtin_fields_of(&self, value: &Value) -> Result<Value, VmError> {
        let entity_name = match value {
            Value::EntityType(name) => name.clone(),
            Value::Entity(instance) => instance.type_name.clone(),
            _ => return Err(VmError::TypeError("Expected entity".into())),
        };

        let metadata = self.entity_metadata.get(&entity_name)
            .ok_or(VmError::EntityNotFound(entity_name))?;

        let fields: Vec<Value> = metadata.fields.iter()
            .map(|f| Value::String(f.name.clone()))
            .collect();

        Ok(Value::List(fields))
    }
}

The overhead of maintaining metadata tables is minimal -- a few kilobytes per entity type. The runtime cost of an introspection call is one hash table lookup plus the cost of building the result value. For a language that targets web applications, where a single database query takes milliseconds, the microsecond cost of introspection is invisible.

The Boundary: Introspection, Not Full Reflection

FLIN provides introspection (examining types and structures) but not full reflection (modifying types and structures at runtime). You can read an entity's fields but you cannot add new fields at runtime. You can check a value's type but you cannot change it. You can enumerate methods but you cannot define new ones dynamically.

This is a deliberate limitation. Full reflection -- as seen in Java, C#, and Ruby -- enables powerful metaprogramming but also enables code that is impossible to understand statically. If any function can add fields to any entity at runtime, the type checker's guarantees become meaningless. A User entity might have a name field in one code path and not in another, depending on which reflection calls executed first.

FLIN's compromise: you can inspect everything, but you can only modify values through the language's normal mechanisms (assignment, set_field, entity CRUD operations). The shape of types is fixed at compile time. The values of fields are dynamic at runtime. This preserves type safety while enabling the patterns that actually matter for web development.

Comparison: Introspection Across Languages

FeatureJavaScriptPythonGoFLIN
Type checkingtypeof (limited)type()reflect.TypeOf()type_of()
Field enumerationObject.keys()dir()reflect.TypeOf().NumField()fields_of()
Dynamic field accessobj[key]getattr()reflect.ValueOf().Field()get_field()
Type nameconstructor.nametype().__name__reflect.TypeOf().Name()type_of()
Imports requiredNoneNone"reflect"None
Type safetyNoneNonePartialFull

JavaScript and Python provide introspection "for free" because they are dynamically typed -- every value already carries its full type information. Go requires importing the reflect package and uses a verbose API. FLIN provides introspection as built-in functions with a simple API and full type safety.

Fifteen Functions for Complete Introspection

The complete introspection API:

  • type_of(value) -- type name as string
  • is_text(value), is_int(value), is_float(value), is_bool(value), is_list(value), is_map(value), is_none(value), is_entity(value) -- type predicates
  • fields_of(entity) -- list of field names
  • field_types_of(entity) -- map of field names to types
  • has_field(entity, name) -- check field existence
  • get_field(entity, name) -- dynamic field read
  • set_field(entity, name, value) -- dynamic field write
  • type_name(value) -- shortcut for entity type name

Fifteen functions that enable dynamic form generation, generic serialization, debug logging, and data transformation -- without sacrificing the type safety that makes FLIN programs reliable.


This is Part 77 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO built runtime introspection into a statically typed programming language.

Series Navigation: - [76] Security Functions: Crypto, JWT, Argon2 - [77] Introspection and Reflection at Runtime (you are here) - [78] Reduce, Map, Filter: Higher-Order Functions - [79] Validation and Sanitization Functions

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles