Skip to main content

Go Interop Guide

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

ABI Stability Notice (v0.5.x): The Go interop ABI is considered "stable preview". Breaking changes are allowed until v0.6.0 but will be announced in the CHANGELOG. See ABI Stability for details.

Integration Options

AILANG provides two ways to integrate with Go:

ApproachUse CasePerformanceComplexity
Runtime EmbeddingDynamic transforms, config DSL, scriptingGood (cached)Low
Compile-time CodegenGame engines, hot paths, type-safe ABIBestMedium
  • Runtime Embedding (v0.6.4+): Embed AILANG interpreter, call functions at runtime
  • Compile-time Codegen: Generate Go code with ailang compile --emit-go

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:

  • Performance-critical game loops
  • Type-safe ABI requirements
  • Static analysis of generated code

Comparison: Embedding vs Codegen

Feature Comparison

FeatureRuntime EmbeddingCompile-Time Codegen
PerformanceGood (cached modules)Best (native Go)
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:

First call:   ~0.001ms (native Go function)
All calls: ~0.001ms (no interpretation overhead)
Memory: Static Go code only

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:

  • ✅ Performance is critical (game loops, hot paths)
  • ✅ Type-safe ABI with Go required
  • ✅ 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:

// Core game loop: compile-time codegen for performance
import "mygame/gen/game"

func gameLoop() {
// Native Go speed
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

For performance-critical use cases, compile AILANG to Go code with ailang compile --emit-go.

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