My CMS has a Go backend and a TypeScript frontend, and keeping their types in step is turning into another project. I'd rather be working on the CMS.

I want the type safety. That's why I keep trying tools which promise to generate this connection for me. The frontend should know what the server sends, and a change to a field should give me something useful to fix.

I've tried so many generation tools, some half finished, some with bugs in the cases I need, others falling behind Go releases. And sometimes the generated code makes the underlying types harder to recognise. I went looking for help with the types, and now I'm trying to see them through another layer.

The whole arrangement keeps feeling like a hack.

Generate the connection, then look after it

An OpenAPI specification describes an API's requests and responses, and a generator can use it to produce a TypeScript client including types for both request and response data. Another route generates the specification from the application's Go declarations before generating TypeScript from it. I've already declared the data in Go, let the tools carry it across and I can stop writing much the same thing in another language. But a field can be right in Go, described differently in the specification, faithfully reproduced from that in TypeScript, and the last generator has done its job while I still have a problem.

Changing the Go code then means regenerating the client and inspecting what moved. If the generator needs a workaround, that becomes part of the application too. A tool update gives me another thing to check before I can get back to the page.

I don't mind working out a difficult bit of the application. What gets to me is doing it over and over to connect two parts I've already written.

Take json.RawMessage. Its underlying representation in Go is a byte slice. Its marshalling method writes the JSON value contained in those bytes. A generator needs to understand that behaviour as well as the declaration.

Here's a small program:

package main

import (
    "encoding/json"
    "fmt"
)

func main() {
    value := struct {
        Reason json.RawMessage `json:"reason"`
    }{Reason: json.RawMessage(`{"code":"edited"}`)}

    encoded, err := json.Marshal(value)
    if err != nil { panic(err) }
    fmt.Println(string(encoded))
}

It prints:

{"reason":{"code":"edited"}}

The field contains a nested JSON object. It hasn't become an array of bytes. The RawMessage documentation describes this marshalling behaviour.

One of my generator's overrides lets me write:

replace json.RawMessage object

For this response an object is exactly what I meant. That looks like the problem dealt with, until I remember that RawMessage can contain any valid JSON value, including a string or a number. The API now needs to enforce my narrower promise. So the override fixes this mapping, but leaves me looking after an exception. I need to read what Go writes, decide what the specification should say, and check what the client gets. All this for a field I wanted to use on a page.

I can fix this mapping, but the frustration is that fixing mappings is becoming work I have to plan around. Another annotation, another exception, another generated file to read. I'd like the connection to stop needing so much of my attention.

The TypeScript compiler is doing its job. We have handed it the wrong job description.

The generator gets a say in the data

There's pressure to keep the communication record small and tightly defined to help control what the generator produces. That's the data transfer object or DTO, the record of fields sent to the client. So I have my application's data, a version arranged for the API, and the generated description the frontend uses. A change can send me through all three while I'm thinking about how much code the generator adds and what the page needs. This part gets to me. The tools are supposed to help me describe the application, but instead I'm arranging parts of it around what the tools can describe.

Could the page use the Go values?

Generated code isn't free either. The generated client adds code to the browser bundle. I wanted a typed connection for the data I'm using, and I'm carrying a lot of generated code to get it.

Then there's the iteration speed. Edit the Go, regenerate, see what broke, get the frontend ready again.... I want to change a page and see it. Having several bits of scaffolding in between makes that slower, even when I've got them all working.

I keep coming back to the size of the original job. The CMS needs a page. Why does getting the data onto that page involve maintaining so much else?

Writing the TypeScript declarations myself would remove the generator from this loop. It would also give me two descriptions to keep in step by hand. I'd be taking back the job I wanted to automate.

I could keep working on the generators. Add support for my cases, inspect their output, keep the overrides. That would improve this arrangement, but I'd still have the same translations to maintain.

What interests me more is changing where the page gets built. If Go prepares the data, could Go also use that data to produce the HTML? The page would read the values I've already declared. There wouldn't be a TypeScript client reconstructing their types for this rendering step.

I'd still want help when I get a field name wrong. I'd also want the code producing the data and the markup using it to be pleasant to work on. Removing the client would give me room to think about both.

Can the page use my Go types directly, without making me maintain another description of them first?

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.