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// go2func Build(id ContainerID) error {3 //4}5 6Build("myContainerId") // compiler error
1// TS2export function build(id: ContainerID) {3 //4}5 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() 4 5// do some other things with your container 6 7const dirList = client.container(containerRef) 8 .withWorkdir('/app') 9 .withMountedDirectory('/app', client.host().directory('.'))10 .withExec(['ls'])11 .stdout()12 13console.log(dirList)
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!