Skip to main content

Go Interop Guide

This guide explains how to integrate AILANG code with Go applications.

Status (2026-10-28): Go codegen (ailang compile --emit-go) is not a performance path. The generated code boxes every value as interface{}, and it has no tail-call elimination: a tail-recursive loop of 10 million steps overflows the Go stack, where ailang run runs it at constant depth. For speed, run on the bytecode VM (ailang run --bytecode, or --strict-bytecode to fail instead of silently falling back to the interpreter). The future of Go codegen is under review.

internal/embed is an internal/ package, so Go only lets code inside the ailang module import it. A separate Go project cannot import it today. Use a child process instead.

Integration Options​

AILANG offers three ways to integrate with Go:

ApproachUse CasePerformanceComplexity
Child processSimulations, game logic, any host languageBytecode VMLow
Runtime EmbeddingDynamic transforms, config DSL, scripting (inside the ailang module)Interpreter (cached)Low
Compile-time CodegenType-safe Go ABI, extern functionsFast for simple code; no tail callsMedium
  • Child process: start ailang run --bytecode and exchange JSON over stdin/stdout
  • Runtime Embedding (v0.6.4+): Embed AILANG interpreter, call functions at runtime
  • Compile-time Codegen: Generate Go code with ailang compile --emit-go

Child process (ailang run --bytecode)​

The host starts the AILANG program as a child process and exchanges newline-delimited JSON over stdin/stdout, one request and one reply per step. The program runs on the bytecode VM, and the host can be written in any language. Stapledon's Voyage runs its simulation this way under Godot.

ailang run --quiet --bytecode --package-dir sim --caps IO --entry main sim/ship.ail

Use --strict-bytecode in CI, so that code which cannot run on the VM fails instead of silently falling back to the slower interpreter.


Runtime Embedding (v0.6.4+)​

The internal/embed package lets you embed AILANG as a scripting/extension language in Go applications.

Quick Start​

package main

import (
"fmt"
"log"

"github.com/sunholo/ailang/internal/embed"
)

func main() {
// Create engine with base path for module resolution
engine := embed.New("/path/to/project")
defer engine.Close()

// Call an AILANG function
result, err := engine.Call(
"transforms/formatter", // module path
"truncate", // function name
"Hello, World!", // args...
10,
)
if err != nil {
log.Fatal(err)
}

// Extract typed result
text, _ := embed.ToString(result)
fmt.Println(text) // "Hello, Wor..."
}

Engine API​

// Create a new engine
engine := embed.New(basePath string) *Engine

// Load a module (optional - Call auto-loads)
engine.Load(modulePath string) error

// Call an exported function
engine.Call(modulePath, funcName string, args ...interface{}) (eval.Value, error)

// JSON-based calling (for language-agnostic integration)
engine.CallJSON(modulePath, funcName string, inputJSON []byte) ([]byte, error)

// Evaluate an expression (requires module context)
engine.Eval(code string) (eval.Value, error)

// Introspection
engine.ListExports(modulePath string) ([]string, error)
engine.HasExport(modulePath, name string) bool

// Cleanup
engine.Close() error

Value Conversion​

The embed package provides bidirectional conversion between Go and AILANG values:

Go to AILANG (FromGo):

Go TypeAILANG Type
nil() (unit)
boolbool
int, int8...int64int
uint, uint8...uint64int
float32, float64float
stringstring
[]bytebytes
[]T[T] (list)
[N]Tarray
map[string]T{...} (record)
struct{...} (record, exported fields)

AILANG to Go (ToGo):

AILANG TypeGo Type
() (unit)nil
boolbool
intint
floatfloat64
stringstring
bytes[]byte
[T] (list)[]interface{}
{...} (record)map[string]interface{}

Type-Safe Extractors​

For type safety, use the specific extractors:

// Extract specific types
value, err := embed.ToInt(result)
value, err := embed.ToFloat(result)
value, err := embed.ToString(result)
value, err := embed.ToBool(result)
value, err := embed.ToBytes(result)
value, err := embed.ToList(result)
value, err := embed.ToRecord(result)

// Check for unit
if embed.IsUnit(result) {
// result is ()
}

JSON Integration​

For language-agnostic use or HTTP APIs:

// JSON input → AILANG → JSON output
inputJSON := []byte(`{"events": [...], "maxLen": 200}`)
outputJSON, err := engine.CallJSON(
"transforms/formatter",
"processEvents",
inputJSON,
)
// outputJSON ready for HTTP response

// Manual JSON conversion
ailangVal, err := embed.FromJSON(jsonBytes)
jsonBytes, err := embed.ToJSON(ailangVal)

Complete Example​

Given this AILANG module:

-- transforms/event_formatter.ail
module transforms/event_formatter

import std/string (length as strlen, substring)

export pure func truncate(text: string, maxLen: int) -> string =
if maxLen == 0 || strlen(text) <= maxLen
then text
else "${substring(text, 0, maxLen)}..."

export pure func countWords(text: string) -> int =
length(split(text, " "))

Call it from Go:

package main

import (
"fmt"
"github.com/sunholo/ailang/internal/embed"
)

func main() {
engine := embed.New(".")
defer engine.Close()

// Call truncate
result, _ := engine.Call(
"transforms/event_formatter",
"truncate",
"The quick brown fox jumps over the lazy dog",
20,
)
text, _ := embed.ToString(result)
fmt.Println(text) // "The quick brown fox ..."

// Call countWords
result, _ = engine.Call(
"transforms/event_formatter",
"countWords",
"hello world foo bar",
)
count, _ := embed.ToInt(result)
fmt.Println(count) // 4

// Using structs
type Event struct {
TurnNum int `json:"turnNum"`
StreamType string `json:"streamType"`
Text string `json:"text"`
}

event := Event{TurnNum: 1, StreamType: "text", Text: "Hello"}
ailangVal, _ := embed.FromGo(event)
// ailangVal is now an AILANG record
}

Limitations​

  1. Pure functions recommended: Embedded modules should be pure for predictability
  2. No hot reloading: Restart engine to pick up module changes
  3. Standalone expressions: Eval("1+2") requires let binding: Eval("let x = 1+2 in x")
  4. Thread safety: Engine is mutex-protected; for parallel calls, use separate engines

When to Use Runtime Embedding​

Good for:

  • Configuration DSLs
  • Data transformation pipelines
  • Plugin/extension systems
  • Scripting in applications
  • Dynamic rule engines

Consider compile-time codegen instead for:

  • Type-safe ABI requirements
  • Static analysis of generated code

For performance-critical loops, use a child process on the bytecode VM.


Comparison: Embedding vs Codegen​

Feature Comparison​

FeatureRuntime EmbeddingCompile-Time Codegen
PerformanceInterpreter (cached modules)Fast for simple code; values boxed as interface{}, no tail calls
Type safetyRuntime errorsCompile-time errors
Hot reloadRestart engineRecompile binary
DeploymentShip .ail filesSingle binary
DebuggingAILANG stack tracesGo stack traces
EffectsPure functions onlyFull effect handlers
ComplexitySimple APIGenerated code to maintain

Performance Characteristics​

Runtime Embedding:

First call: ~50-100ms (parse + compile + type check)
Cached call: ~0.1-1ms (lookup + eval)
Memory: Runtime + cached ASTs

Compile-Time Codegen (measured 2026-10-28, fib(32)):

Generated Go: 0.030s (every value boxed as interface{})
Bytecode VM: 0.33s (ailang run --strict-bytecode)
Hand-written Go: 0.005s
Tail-recursive loop: stack overflow at 10M steps (no tail-call elimination)

When to Choose Each​

Choose Runtime Embedding when:

  • ✅ AILANG modules change frequently
  • ✅ End users customize behavior
  • ✅ Plugin/extension architecture
  • ✅ Prototyping and development
  • ✅ Pure data transformation
  • ✅ Minimal integration effort needed

Choose Compile-Time Codegen when:

  • ✅ Type-safe ABI with Go required
  • ✅ Your code has no deep recursion (there are no tail calls, so deep recursion overflows the stack)
  • ✅ Need full effect handler support
  • ✅ Single-binary deployment preferred
  • ✅ Static analysis of generated code needed

Hybrid Approach​

You can use both approaches in the same project:

// Typed step function: compile-time codegen for a Go ABI
import "mygame/gen/game"

func gameLoop() {
result := game.Step(world, input)
}

// Config/scripting: runtime embedding for flexibility
import "github.com/sunholo/ailang/internal/embed"

func loadMods() {
engine := embed.New("./mods")
for _, mod := range modPaths {
engine.Call(mod, "init", gameState)
}
}

Code Comparison​

Same AILANG module:

module example/math

export pure func add(a: int, b: int) -> int = a + b

With Runtime Embedding:

engine := embed.New(".")
result, err := engine.Call("example/math", "add", 1, 2)
sum, _ := embed.ToInt(result) // sum = 3

With Compile-Time Codegen:

ailang compile --emit-go --package-name math example/math.ail
import "myproject/gen/math"

sum := math.Add(1, 2) // sum = 3

Compile-Time Codegen​

To get a typed Go API, compile AILANG to Go code with ailang compile --emit-go. This is not a speed path: see the status note at the top of this page.

Type Mapping​

AILANG types map to Go types as follows:

AILANG TypeGo TypeNotes
intint6464-bit signed integer
floatfloat6464-bit floating point
stringstringUTF-8 string
boolboolBoolean
()struct{}Unit type (empty struct)
[T][]TSlice of element type
{ field: T }*TypeNamePointer to generated struct
ADT variants*TypeNamePointer to discriminated union

Extern Functions​

Extern functions allow you to implement performance-critical code in Go while maintaining type safety with AILANG.

Declaring Extern Functions​

In your AILANG file:

-- Declare types
type Coord = { x: int, y: int }
type Path = [Coord]

-- Declare extern function (implemented in Go)
extern func find_path(world: World, from: Coord, to: Coord) -> Path

Generating Stubs​

Run the compiler to generate Go stubs:

ailang compile --emit-go world.ail

This generates extern_stubs.go with function signatures to implement:

Multi-File Compilation​

When your game has multiple AILANG modules, compile them together. You can pass a directory to automatically discover all .ail files (v0.5.5+):

# Pass a directory - discovers all .ail files automatically
ailang compile --emit-go --package-name game sim/

# Or pass multiple files explicitly
ailang compile --emit-go --package-name game step.ail npc_ai.ail camera.ail

# Mix directories and files
ailang compile --emit-go --package-name game sim/ extra.ail

This generates one output file per source file (v0.5.5+):

gen/game/
├── types.go # All ADT types (merged from all files)
├── debug_types_debug.go # Debug effect (//go:build !release)
├── debug_types_release.go# Debug effect no-ops (//go:build release)
├── handlers.go # Effect handler interfaces
├── runtime.go # Shared runtime helpers
├── step.go # Functions from step.ail
├── npc_ai.go # Functions from npc_ai.ail
└── camera.go # Functions from camera.ail

Benefits of per-file output:

  • Smaller, more navigable files
  • Easier to correlate generated code with source
  • Better IDE navigation

Important: Compile all your .ail files in a single command. Compiling files separately will overwrite previous output.

# ✅ CORRECT - Compile all files together
ailang compile --emit-go sim/

# ❌ WRONG - Each compile overwrites the previous
ailang compile --emit-go step.ail # Generates types.go
ailang compile --emit-go npc_ai.ail # OVERWRITES types.go!

Generated Extern Stubs​

The compiler generates extern_stubs.go with function signatures to implement:

// Find_path is an extern function declared in AILANG.
//
// AILANG signature:
// extern func find_path(world: World, from: Coord, to: Coord) -> Path
//
// Implement this function to provide the behavior.
func Find_path(world *World, from *Coord, to *Coord) []*Coord {
panic("not implemented: find_path")
}

Implementing Extern Functions​

Replace the panic with your implementation:

func Find_path(world *World, from *Coord, to *Coord) []*Coord {
// Your A* pathfinding implementation here
return aStarSearch(world, from, to)
}

Restrictions​

  1. Monomorphic only: Extern functions cannot use type parameters (generics)

    -- ERROR: extern functions cannot be polymorphic
    extern func identity[T](x: T) -> T
  2. No underscore prefix: Extern function names cannot start with _

    -- ERROR: underscore prefix reserved for builtins
    extern func _internal_helper(x: int) -> int
  3. Explicit return type: Extern functions must declare their return type

    -- ERROR: must have explicit return type
    extern func do_something(x: int)

    -- OK
    extern func do_something(x: int) -> ()

Error Messages​

Common errors and their solutions:

EXT001: Underscore prefix not allowed​

EXT001: extern function '_helper' cannot have underscore-prefix (reserved for builtins)
Suggestion: Use a public name without leading underscore

Solution: Remove the leading underscore from the function name.

EXT002: Polymorphic not supported​

EXT002: extern functions cannot be polymorphic (no type parameters)
Suggestion: Extern functions must use concrete types like int, float, string

Solution: Use concrete types instead of type parameters.

EXT003: Missing return type​

EXT003: extern functions must have explicit return type
Suggestion: Add '-> ReturnType' after parameters

Solution: Add an explicit return type annotation.

Best Practices​

  1. Keep extern functions focused: Extern functions should do one thing well
  2. Document type expectations: Add comments explaining any type constraints
  3. Handle errors explicitly: Return error values rather than panic
  4. Test thoroughly: Write Go tests for your extern implementations

ADT to Go Mapping Rules​

When AILANG Algebraic Data Types (ADTs) are compiled to Go, the following rules apply:

Record Types​

AILANG record types become Go structs with pointer semantics:

type Player = { name: string, health: int, position: Coord }

Generates:

type Player struct {
Name string
Health int64
Position *Coord
}

Rules:

  • Field names are capitalized (exported)
  • Nested records use pointer types
  • All record type function parameters/returns use pointers

Sum Types (Variants)​

AILANG sum types (ADTs with multiple constructors) become Go structs with a discriminator:

type Option[T] =
| Some(T)
| None

Generates:

type Option struct {
Tag OptionTag
Some *SomeData // nil if Tag != OptionTagSome
}

type OptionTag int

const (
OptionTagNone OptionTag = iota
OptionTagSome
)

type SomeData struct {
Value interface{} // Generic types use interface{}
}

Rules:

  • Discriminator field is named Tag
  • Constructor data stored in nullable fields
  • Use type switch on Tag for pattern matching

Effect Handlers​

AILANG effects (Rand, Clock, FS, Net, Env, AI) are compiled to Go interface calls. The generated code expects you to implement handler interfaces and initialize them before use.

How Effect Handler Codegen Works​

When you compile AILANG code with effects:

module game/step

import std/rand (rand_int)

export func rollDice() -> int ! {Rand} {
rand_int(1, 6)
}

The compiler generates:

  1. Handler interfaces in effects.go:
// RandHandler provides the Rand effect implementation.
type RandHandler interface {
RandInt(min, max int64) int64
}
  1. Handlers struct to collect all handlers:
type Handlers struct {
Debug DebugHandler
Rand RandHandler
Clock ClockHandler
FS FSHandler
Net NetHandler
Env EnvHandler
AI AIHandler
}
  1. Init function to initialize the global handlers:
func Init(h Handlers) {
handlers = h
}

var handlers Handlers
  1. Function code that calls handlers:
func RollDice() int64 {
return handlers.Rand.RandInt(1, 6)
}

Implementing Handlers​

Game developers implement the handler interfaces for their platform:

package main

import "mygame/gen/game"

// Implement RandHandler
type MyRandHandler struct {
seed uint64
}

func (r *MyRandHandler) RandInt(min, max int64) int64 {
// Your deterministic RNG implementation
r.seed = r.seed*1103515245 + 12345
return min + int64(r.seed)%(max-min+1)
}

func main() {
// Initialize handlers before using any AILANG code
game.Init(game.Handlers{
Rand: &MyRandHandler{seed: 12345},
// ... other handlers
})

// Now you can call AILANG functions
result := game.RollDice()
}

Available Effect Handlers​

HandlerMethodsPurpose
DebugHandlerLog(msg, loc), Assert(cond, msg, loc), Collect()Debugging and tracing
RandHandlerRandInt(min, max), RandFloat()Deterministic random numbers
ClockHandlerNow(), DeltaTime()Time and game loop timing
FSHandlerExists(path), ReadFile(path), WriteFile(path, content)File system operations
NetHandlerHttpGet(url), HttpPost(url, body)Network requests
EnvHandlerGetEnv(key)Environment variables
AIHandlerCall(prompt, opts)AI model calls

Handler Implementation Tips​

  1. Keep handlers stateless if possible - easier to test and reason about
  2. Use deterministic implementations for Rand - enables replay and testing
  3. Mock handlers for testing - inject test doubles via Init()
  4. Initialize once at startup - don't call Init() multiple times

Legacy: Extern Functions​

For custom operations not covered by the built-in effect handlers, you can use extern functions (see Extern Functions above).

Debug Effect Host Contract​

The Debug effect provides structured tracing for debugging and testing. It is a ghost effect - erasable in release mode for zero-cost production builds.

Generated Files​

Running ailang compile --emit-go generates:

FilePurposeBuild Tag
debug_types_debug.goFull implementation (collects traces)//go:build !release
debug_types_release.goNo-op implementation (zero cost)//go:build release

DebugContext Interface​

The generated DebugContext implements this contract:

type DebugContext struct {
// ... internal state
}

// Host lifecycle methods (HOST-ONLY - not callable from AILANG)
func NewDebugContext() *DebugContext
func (d *DebugContext) SetTimestamp(t int64) // Host sets logical time
func (d *DebugContext) Collect() DebugOutput // Host reads accumulated data
func (d *DebugContext) Reset() // Clear for next step

// Effect operation handlers (called by generated AILANG code)
func (d *DebugContext) Log(msg, location string)
func (d *DebugContext) Assert(cond bool, msg, location string)

// Query methods
func (d *DebugContext) HasFailedAssertions() bool
func (d *DebugContext) FailedAssertions() []AssertionResult

Output Types​

type DebugOutput struct {
Logs []LogEntry
Assertions []AssertionResult
}

type LogEntry struct {
Message string // Log message
Location string // Source location (file.ail:42)
Timestamp int64 // Logical time (host-defined)
}

type AssertionResult struct {
Passed bool // Whether assertion passed
Message string // Assertion message
Location string // Source location (file.ail:42)
}

Host Integration Example​

func main() {
debugCtx := game.NewDebugContext()

for tick := 0; tick < 1000; tick++ {
// 1. Reset and set timestamp for this step
debugCtx.Reset()
debugCtx.SetTimestamp(int64(tick))

// 2. Run AILANG code (Debug.log/assert calls accumulate)
world, output, err := game.Step(world, input, debugCtx)
if err != nil {
log.Fatal(err)
}

// 3. Host collects and handles debug output
debugData := debugCtx.Collect()

// Check for assertion failures
if debugCtx.HasFailedAssertions() {
for _, a := range debugCtx.FailedAssertions() {
log.Printf("ASSERTION FAILED at %s: %s", a.Location, a.Message)
}
}
}
}

Building Debug vs Release​

# Debug mode (default) - Debug effect collects traces
go build .

# Release mode - Debug effect is zero-cost no-ops
go build -tags release .

JSON Schema for DebugOutput​

The DebugOutput structure can be serialized to JSON for external tooling:

{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "DebugOutput",
"type": "object",
"properties": {
"logs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"message": { "type": "string" },
"location": { "type": "string", "pattern": "^.+:\\d+$" },
"timestamp": { "type": "integer" }
},
"required": ["message", "location", "timestamp"]
}
},
"assertions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"passed": { "type": "boolean" },
"message": { "type": "string" },
"location": { "type": "string", "pattern": "^.+:\\d+$" }
},
"required": ["passed", "message", "location"]
}
}
},
"required": ["logs", "assertions"]
}

Example JSON output:

{
"logs": [
{"message": "tick=1: delta=4", "location": "impl.go:Step", "timestamp": 1},
{"message": "tick=2: delta=-2", "location": "impl.go:Step", "timestamp": 2}
],
"assertions": [
{"passed": true, "message": "tick should increase", "location": "impl.go:Step"},
{"passed": true, "message": "seed should be preserved", "location": "impl.go:Step"}
]
}

Design Principles​

  1. Write-only from AILANG: Code can only write Debug.log/Debug.assert, cannot read its own trace
  2. Host-controlled lifecycle: Only the host can Collect() and Reset()
  3. Ghost effect: Erased at build time in release mode, not just no-op at runtime
  4. Auto-injected locations: Source positions added by compiler, not passed by user
  5. Abstract timestamps: Host defines what timestamp means (tick, test index, etc.)

Working Example​

See examples/sim_stub/ for a complete working example demonstrating:

  • Type definitions in AILANG
  • Extern function declarations
  • Generated Go types and stubs
  • Go implementation
  • Deterministic simulation
  • Debug effect usage with host-controlled lifecycle
# Run the example (debug mode)
cd examples/sim_stub
make run

# Build in release mode (Debug operations become no-ops)
go build -tags release .
./sim_stub

ABI Stability​

Stability Promise (v0.5.x)​

The Go interop ABI is considered "stable preview" for v0.5.x:

ComponentStabilityNotes
Type mapping (primitives)Stableint→int64, float→float64, etc.
Record type generationStableStruct field ordering preserved
Extern function signaturesStableGenerated stubs won't break
ADT discriminator formatPreviewMay change before v0.6.0
Generic type handlingPreviewCurrently uses interface

What "Stable Preview" Means​

  • Safe to use in production for non-generic, non-ADT code
  • Breaking changes announced in CHANGELOG with migration path
  • Full stability guaranteed starting v0.6.0

Migration Guide: v0.4.x to v0.5.x​

If upgrading from v0.4.x:

  1. New compile subcommand: Use ailang compile --emit-go instead of any previous codegen method
  2. Extern functions: New feature - no migration needed
  3. Type mapping unchanged: Existing type correspondences still apply
  4. Generated code location: Now outputs to gen/<package>/ by default

Reporting Issues​

If you encounter ABI-related issues:

  1. Check the CHANGELOG for known issues
  2. File an issue at github.com/sunholo-data/ailang/issues
  3. Include: AILANG version, Go version, generated code sample