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:
- Create zero-value struct
- Fill in provided JavaScript arguments positionally
- 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:
string→stringint/int8-64/uint/uint8-64/float32/float64→numberbool→boolean*T→T | null(orTif optional)map[string]any→Record<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.gointernal/runtime/standard/fetch_test.gotest_data/*.tstest 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