A Piko component ought to be able to call an ordinary Go helper like this:

func Label(id int) string {
    return "item-" + strconv.Itoa(id)
}

The component supplies twenty-three and gets item-23. The useful function is already in the server. Asking the page author to turn that integer into JSON would be a fairly miserable way to reach it.

After all the Go and TypeScript glue, I would quite like to be finished writing connection code. (It has taken me a whole interpreter to get both sides speaking Go.) Surely that ought to help.

It does, but the helper was compiled with the server. The component runs in my interpreter. Its values live in typed banks, separate arrays for different kinds of value. It has its own instructions and type metadata too.

I still have a bridge to write. It feels faintly ridiculous to need one between two bits of Go, even while I can see why it's there.

Getting the same values back

The application registers the functions and types it chooses to expose under a package path. For Label, that gives me a signature with an integer parameter and a string result. The host has made this helper available, so I can concentrate on making the call behave properly.

integer register → native int → Label
Label → native string → string register

Even that short trip needs a conversion. My integer bank may use a broader representation than the helper's native int. Knowing the number is twenty-three doesn't let me hand over whichever storage contains it.

The general call path uses reflection to inspect the host function and call it dynamically. Specialised adapters handle supported signatures. Both have to preserve the same Go behaviour, including what happens to the caller's values afterwards.

What happens to the caller

An array parameter gives the function a copy:

func changeArray(items [2]int) {
    items[0] = 9
}

items := [2]int{1, 2}
changeArray(items)
fmt.Println(items) // [1 2]

A slice parameter copies a small header holding a pointer, length and capacity. The pointer still leads to the caller's backing array:

func changeSlice(items []int) {
    items[0] = 9
}

items := []int{1, 2}
changeSlice(items)
fmt.Println(items) // [9 2]

I would love one convenient copying rule for this bridge, but these two functions won't give me one. Deep-copying everything breaks the slice example, while sharing the array's mutable storage breaks the array example, because what must be copied and what stays shared is already part of the Go operation.

Array arguments copy values; slice arguments share backing storage
Figure 1 - Follow the caller's next read. An array mutation stays with the callee's copy. A slice element mutation reaches shared backing storage.

The caller reads again. That's when I can see whether I got it right. The native function can return successfully while leaving the wrong value behind.

Strings bring another responsibility. Native code can retain a string after the call. Its backing bytes must survive any interpreter storage reuse. The arena-string article follows that problem further.

A type the host hasn't compiled

With Label, I can inspect the compiled function's signature to find the argument and result types. Go has already compiled both. Then a component can declare a type after the server has been built:

type Customer struct {
    ID int64
}

Making the page author arrange all their data around types my server happened to compile would be a pretty limiting component system. Supporting this declaration leaves me responsible for its representation, identity and methods, including what happens when the customer is saved in an interface. That needs a value copy too. If I save Customer{ID: 101} as any and then change the original customer's ID to 202, the saved value must still hold 101 rather than following the original's mutable storage. This is a copy I have to pay for. reflect.StructOf looks promising here. It can construct a struct from fields the host hasn't seen before, so I can describe the page's ID field without rebuilding the server. What happens if I use it for two types with the same fields?

fields := []reflect.StructField{
    {Name: "ID", Type: reflect.TypeFor[int64]()},
}
a := reflect.StructOf(fields)
b := reflect.StructOf(fields)
fmt.Println(a == b) // true

Both calls describe the same anonymous type. That's sensible for reflection, but it loses something I need for these Go declarations:

type Customer struct{ ID int64 }
type Invoice struct{ ID int64 }

Customer and Invoice must stay distinct even though their fields agree. Passing the same field list to StructOf can't carry that distinction. I need something in the representation that belongs to this particular type.

I add an unexported, zero-sized identity field. It carries no application payload, but gives the constructed representation its own identity in reflection.

I'm less delighted by the hidden field that comes with that solution. The author wrote one ID. I've added something that formatting and field inspection must keep out of their way. Even a zero-sized field can affect the surrounding layout:

type Plain struct{ ID int64 }
type Marked struct {
    ID int64
    marker struct{}
}

On amd64 with Go, Plain occupies eight bytes. Marked occupies sixteen. There are no payload bytes in marker, yet putting it last still costs space.

And the methods? Reflection can't attach arbitrary interpreted methods to a new native named type. I keep that method information in the interpreter and route calls through it. The reflection API helps me build representations, but I'm still maintaining the language behaviour around them.

The page can also ask for one helper that works with Customer, Invoice or an ordinary integer. A type parameter lets its author write that helper once:

func First[T any](items []T) T {
    return items[0]
}

For First[Customer], the input becomes []Customer and items[0] produces one Customer. The compiler needs to read that value and put it in a destination which can hold the struct. For First[int], the input and destination change together to use integers.

That short [T any] looks innocent in the component. I have a soft spot for little bits of syntax like this, they look so tidy on the page. I'd like the page author to keep that pleasure even while I'm grumbling about everything underneath.

In a larger generic helper, a local variable declared as T has to use the chosen type too. Fixing only the signature would leave that local waiting for a type that is no longer a parameter. Every dependent part needs the answer. I follow one specialisation all the way through before asking another call to use it, and once I've done that, I'm certainly keeping the specialised body. Another First[int] can use the same instructions with fresh call storage. The typed register banks give each operation a known representation. A page calling the same helper again should get to use the answer I've already worked out.

A recursive generic call can ask for the specialisation I'm still compiling. I register its entry before descending into the body so that call can find it. Otherwise compiling a recursive function becomes an unexpectedly enthusiastic demonstration of recursion.

The host build has already happened

The page can write the same First[Customer] call if I move the helper into a package compiled with the server. I still know the type argument, so it feels as though I have enough to proceed. Really, it's only enough to describe the call. My compiler has no body to substitute Customer into, and no native entry point for that combination.

A generated wrapper can expose native versions compiled for a fixed set of types. These are instantiations. If the host build supplied strings and integers, the wrapper has entry points for those. The page's new Customer isn't among them.

Where the body livesWhat the interpreter has available
Interpreted sourceA body it can compile after substituting type arguments
Compiled host packageNative entry points that the host build made available

This is frustrating because I can understand the call and know its type argument, yet still have nothing native to call. A runtime reflect.Type won't make the Go compiler generate another function inside the running server. Listing every type a page might invent is a to-do list with no bottom. Strings, integers, every struct the next page author might declare.... I'd run out of patience long before I ran out of types.

Which side is running?

Passing a function to the host sends work the other way. Native Go receives an ordinary function value, but the body belongs to the interpreter. A wrapper takes execution back into the interpreter when the host calls it.

The host can keep that callback. It can use it after the invocation that created it has returned.

I can't treat the bridge as a trip out and straight back. The original call's working storage may have been reused. The wrapper still needs valid captured state on that later call.

Calling a native helper hands control to Go for as long as that helper runs, and its loop and file I/O don't pass through my bytecode handlers, so my instruction count doesn't measure them. Cancellation needs the helper's cooperation too.

After all that, the page author gets to write Label(23), receive item-23 and carry on. That's still the experience I'm after. The copying rules, hidden fields and missing native instantiations are my problem, and keeping them out of that little call is quite enough bridge maintenance for me.

This entry came over from blog.haddon.me. I’ve tidied up the grammar and flow, bringing it closer to how I write now. I used AI to help with the grammar, structure and flow. I have dyslexia, and my writing often comes out as a rambling scrawl, so it can be hard to see what I was actually working on at the time. I think the tidied versions are easier to digest and more interesting to read. The artefacts got the same tidy-up while I was at it.

  • Related article

    I'm tired of maintaining the glue

    I want the Go backend and TypeScript frontend to agree. Maintaining the generators between them is slowing down the work.

  • Related article

    When a string outlives its arena

    Copying a saved string protects its text. What lets me release the old slabs still holding temporary strings?

  • Read next

    Lying through my NIC

    Making a Linux decoy pass an OS check is a start. Keeping the disguise and its HTTP connections working is giving me rather more trouble.