Files
poiesis/plans/BUILTIN.md
2026-01-27 12:55:55 -05:00

7.5 KiB

Builtin System Design

Overview

Type-safe builtin system for exposing Go functions to TypeScript/JavaScript with automatic type conversion and defaults support.

Core Design

Single Argument Pattern

All builtins accept a single struct argument on the Go side, but are called as multi-argument functions on the JavaScript side.

type FetchArgs struct {
    URL     string `json:"url"`
    Options *FetchOptions `json:"options"`
}

func Fetch(args FetchArgs) (*FetchResult, error) {
    // Implementation
}

// JavaScript calls:
// fetch("https://example.com")
// fetch("https://example.com", { method: "POST" })

Mapping Rules:

  • JavaScript arguments map positionally to struct fields in order
  • Field names in JavaScript come from json:"" struct tag, or field name if tag is omitted
  • Missing JavaScript arguments only allowed for pointer fields (non-pointer fields must always be provided)

Default Values

Argument structs must implement Defaults() receiver method to provide default values:

type FetchOptions struct {
    Method  string `json:"method"`
    Headers map[string]string `json:"headers"`
}

func (f *FetchOptions) Defaults() *FetchOptions {
    if f.Method == "" {
        f.Method = "GET"
    }
    return f
}

// If called with just URL, Options will be nil initially,
// then we create default: &FetchOptions{Method: "GET"}

Defaults flow:

  1. Create zero-value struct
  2. Fill in provided JavaScript arguments positionally
  3. Call Defaults() on the struct to fill in remaining defaults

Empty Arguments

Zero-argument builtins must still use a struct (can be empty):

// Helper type for no-argument builtins
type EmptyArgs struct {}

func Ping(args EmptyArgs) bool {
    return true
}

// JavaScript: ping()

Registration API

package builtin

// RegisterBuiltin registers a Go function as a builtin
// The function must accept a single struct argument T and return (R, error) or R
func RegisterBuiltin[T any, R any](name string, fn func(T) (R, error))

Usage:

func init() {
    builtin.RegisterBuiltin("fetch", Fetch)
    builtin.RegisterBuiltin("add", Add)
}

Type Conversion

JavaScript → Go

Automatic conversion from goja values to Go struct fields:

JavaScript Go Type
string string
number int, int8-64, uint, uint8-64, float32, float64
boolean bool
object/map map[string]any
null/undefined nil (for pointer fields only)

Struct Field Conversion:

  • Primitives: converted by type
  • Maps: map[string]any → Go map with string keys
  • Nested structs: Recursive conversion to nested struct

Go → JavaScript (Return Types)

Automatic conversion from Go return values to JavaScript:

Go Type JavaScript
string, number, bool primitive
map[string]any object
map[string]string object
[]any array
struct object with fields from json tags

Struct Return Types:

  • Auto-converted to JavaScript objects
  • Field names from json:"" struct tags
  • Nested structs become nested objects
  • No custom converters needed

Example:

type FetchResult struct {
    OK      bool   `json:"ok"`
    Status  int    `json:"status"`
    Body    string `json:"body"`
}

// Returns: { ok: true, status: 200, body: "..." }

Error Handling

Go errors are automatically converted to JavaScript exceptions:

func Fetch(args FetchArgs) (*FetchResult, error) {
    if someError {
        return nil, fmt.Errorf("fetch failed")
    }
    return result, nil
}

// JavaScript: fetch calls that error will throw

TypeScript Declaration Generation

TypeScript definitions are auto-generated from Go function signatures:

// Go:
func Fetch(args FetchArgs) (*FetchResult, error)

// Generated TypeScript:
declare function fetch(url: string, options?: FetchOptions): FetchResult | never;

Type mapping:

  • stringstring
  • int/int8-64/uint/uint8-64/float32/float64number
  • boolboolean
  • *TT | null (or T if optional)
  • map[string]anyRecord<string, any>
  • struct → Interface with fields

Optional parameters:

  • Pointer fields become optional parameters with T | null
  • Non-pointer fields are required

Implementation Notes

Reflection-Based Wrapping

func createWrapper[T any, R any](fn func(T) (R, error)) func(*goja.Runtime, goja.FunctionCall) goja.Value {
    return func(vm *goja.Runtime, call goja.FunctionCall) goja.Value {
        // 1. Create zero-value struct
        var args T
        argsValue := reflect.ValueOf(&args).Elem()

        // 2. Map JavaScript args to struct fields positionally
        for i := 0; i < min(len(call.Arguments), argsValue.NumField()); i++ {
            jsArg := call.Arguments[i]
            field := argsValue.Field(i)

            // Skip nil/undefined if field is pointer
            if goja.IsUndefined(jsArg) || goja.IsNull(jsArg) {
                if field.Kind() == reflect.Ptr {
                    continue
                }
            }

            // Convert and assign
            converted, err := convertJSValueToGo(vm, jsArg, field.Type())
            if err != nil {
                panic(err)
            }
            field.Set(reflect.ValueOf(converted))
        }

        // 3. Call Defaults() if defined
        if defaults, ok := args.(interface{ Defaults() T }); ok {
            args = defaults.Defaults()
        }

        // 4. Call the Go function
        result, err := fn(args)
        if err != nil {
            panic(err)
        }

        // 5. Convert return value to JavaScript
        return convertGoValueToJS(vm, reflect.ValueOf(result))
    }
}

Field Name Extraction

func getFieldName(field reflect.StructField) string {
    jsonTag := field.Tag.Get("json")
    if jsonTag != "" && jsonTag != "-" {
        name, _, _ := strings.Cut(jsonTag, ",")
        return name
    }
    return field.Name
}

Migration Examples

Old → New

Old (current system):

func add(a, b int) int {
    return a + b
}

// Register: builtin.RegisterBuiltin("add", add)
// JS: add(5, 10)

New system:

type AddArgs struct {
    A int `json:"a"`
    B int `json:"b"`
}

func add(args AddArgs) int {
    return args.A + args.B
}

// Register: builtin.RegisterBuiltin("add", add)
// JS: add(5, 10)          // positional
// JS: add(a=5, b=10)      // named (if supported)

Old fetch:

func Fetch(url string, options map[string]any) (*FetchResult, error)

// Requires custom converter for result

New fetch:

type FetchArgs struct {
    URL     string `json:"url"`
    Options *FetchOptions `json:"options"`
}

type FetchOptions struct {
    Method  string `json:"method"`
    Headers map[string]string `json:"headers"`
}

func (o *FetchOptions) Defaults() *FetchOptions {
    if o.Method == "" {
        o.Method = "GET"
    }
    return o
}

type FetchResult struct {
    OK      bool   `json:"ok"`
    Status  int    `json:"status"`
    Body    string `json:"body"`
}

func Fetch(args FetchArgs) (*FetchResult, error) {
    // Implementation - no custom converter needed
}

// JS: fetch("https://example.com")
// JS: fetch("https://example.com", { method: "POST" })

Testing

All existing tests must pass after migration:

  • internal/runtime/runtime_test.go
  • internal/runtime/standard/fetch_test.go
  • test_data/*.ts test files

Verify:

  • TypeScript definitions are correct
  • Positional argument mapping works
  • Defaults are applied correctly
  • Named JSON tags work
  • Error handling propagates
  • Return value conversion works