Improving type safety with real custom scalars in TypeScript

Leo Sjöberg • July 15, 2023

TypeScript’s type system is a bit loose, largely because it eventually has to compile down to JavaScript. One of the big limitations with its type system is that custom scalar types only act as type aliases, unlike most other languages where they are proper types. Take the following type declaration:

1type ContainerID = string

This code is valid in both Go and TS, but will work very differently. In Go, you’ll get a type error if you try to pass a string to a function requiring a ContainerID:

1// go
2func Build(id ContainerID) error {
3 //
6Build("myContainerId") // compiler error
1// TS
2export function build(id: ContainerID) {
3 //
6build("myContainerId") // runs just fine

Particularly when building libraries, you might expect one function to receive another function’s return value as its argument. In the CI tool Dagger, for example, usage may look something like this:

1const containerRef = await client.container()
2 .from("alpine:latest")
3 .id()
5// do some other things with your container
7const dirList = client.container(containerRef)
8 .withWorkdir('/app')
9 .withMountedDirectory('/app','.'))
10 .withExec(['ls'])
11 .stdout()

This works great, but in previous versions of Dagger, ContainerID was just a type alias:

1type ContainerID = string

This meant that the return value of id() was treated as a string, and this led to some hard-to-understand problems.

Dagger’s container IDs are base64-encoded JWTs representing the container, and when you pass a container ID into client.container(), Dagger’s library code will base64-decode it, and expect to see an object, or the string literal null (representing an empty container, the equivalent of a completely empty Dockerfile).

I ran into a problem where I intended to use from, but because the SDK was using type aliases (and IntelliJ IDEs simplify aliases to their concrete types for display, meaning my IDE showed string), I didn’t realise this and instead passed an image name to container(). Reusing the above example:

1const containerRef = await client.container('alpine:latest').id()

Because Dagger just decodes the data, I got one of the worst errors I’ve seen:

1"invalid character 'ç' in literal null (expecting 'u')"

This all happened because ContainerID is not a distinct type.

Solving it with a union type

You can work around this by creating a union type with your string:

1type ContainerID = string & { __ContainerID: never }

This does require manually asserting your string as this type, which is done in Dagger’s library code. This means that consumers will always get a unique type that will result in compiler errors; writing the exact same code as above, you’d now get a TypeError!

Inside of Dagger’s code, the usage will simply be

1return containerID as ContainerID

This can be an extremely useful tool for ensuring the correct values get passed around, even when the underlying type is a scalar.

You can use this in the same way you use custom string types in Go, giving you compiler errors for passing the wrong type of string!