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.
entity 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?
type_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:
fn 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:
is_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) // trueThese 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?
fields = 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?
types = 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?
has_field(user, "name") // true
has_field(user, "phone") // falsehas_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
// 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:
fn 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:
entity 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)
} ```
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:
fn 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:
user = 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:
pub 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:
pub struct Vm {
// ... other fields
entity_metadata: HashMap<String, EntityMetadata>,
}impl Vm {
fn builtin_fields_of(&self, value: &Value) -> Result
let metadata = self.entity_metadata.get(&entity_name) .ok_or(VmError::EntityNotFound(entity_name))?;
let fields: Vec
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
| Feature | JavaScript | Python | Go | FLIN |
|---|---|---|---|---|
| Type checking | typeof (limited) | type() | reflect.TypeOf() | type_of() |
| Field enumeration | Object.keys() | dir() | reflect.TypeOf().NumField() | fields_of() |
| Dynamic field access | obj[key] | getattr() | reflect.ValueOf().Field() | get_field() |
| Type name | constructor.name | type().__name__ | reflect.TypeOf().Name() | type_of() |
| Imports required | None | None | "reflect" | None |
| Type safety | None | None | Partial | Full |
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 stringis_text(value),is_int(value),is_float(value),is_bool(value),is_list(value),is_map(value),is_none(value),is_entity(value)-- type predicatesfields_of(entity)-- list of field namesfield_types_of(entity)-- map of field names to typeshas_field(entity, name)-- check field existenceget_field(entity, name)-- dynamic field readset_field(entity, name, value)-- dynamic field writetype_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