Reference by value in TypeScript

I am currently examining ways to improve development UX for a low-code platform that I’m opening up to the public.

As I started experimenting with TypeScript I tweeted about it.

Because I received quite a lot of inquiries, I decided to blog about it.

This is potentially the first post in a series about leveraging the TypeScript type system to the extreme.

The context

I am currently extending the Virtual Sales Lab platform, so we can open it up to external developers.

Virtual Sales Lab is a SaaS that provides online 3D configurators for sales and marketing; below you can see a screenshot from one of our customers' configurator; you can test a copy of the configurator here.

Virtual Sales Lab

It allows a developer to build something usable in a couple of hours, and a full blown configurator usually takes 1 to 4 weeks.

However, it requires a little bit of effort to get acquainted with the platform as a developer.

As the intent is to make this platform open to all developers as a low-code platform with a low barrier to entry, I was wondering if I could make the developer experience extremely user friendly.

I am a strong believer of leveraging existing tools as much as possible, so I started experimenting with TypeScript. It turns out a lot is possible by being creative with the type system.

My first experiment: referential integrity.

Our platform takes in a strongly-typed JSON file and converts it into an online 3D configurator like the one I mentioned above.

As the JSON is type-checked, you cannot provide an absolute bogus representation, but bugs are still possible.

Here is an example of a potential bug:

    "doorpanel": {
        "elementType": "panel",
        "baseMaterialId": "wood ",
        "width": 100,
        "height": 100,
        "opts": {
            "thickness": 2
        }
    }

Did you spot the problem? Instead of "wood", I accidentally typed "wood ".

Luckily it raises a proper error at runtime, which allows you to fix the problem, but it does require a round-trip to the engine.

As short feedback loops are the bee’s knees, I wondered if there was a way to catch this at compile-time….

Basics first: whatever happened to TypeScript?

As my TypeScript was a bit rusty - I was mostly consulting over the past few years -, this was like a TypeScript-101 experience for me, so let’s start with the basics…

Let’s say we have 2 simple types: a TProductCatalog and a TOrder type. How would we represent these?

type TProductCatalog = Record<string, {
    name: string,
    price: number
}>;

type TOrder = {
    customerId: string,
    orderDate: Date,
    orderLines: { productId: string, quantity: number }[]
}

This would allow you to define the following constants:

const productCatalog: TProductCatalog = {
    "apple": { name: "An apple", price: .5 },
    "pear": { name: "Pear", price: .6 },
    "pineapple": { name: "A pineapple - or Ananananananas as they say in Dutch", price: 2.5 },
}

const order: TOrder = {
    customerId: "ToJans",
    orderDate: new Date(),
    orderLines: [
        { productId: "apple", quantity: 5 },
        { productId: "pear", quantity: 5 },
        { productId: "pine-apple", quantity: 5 },
    ]
}

You can try adjusting the order or product catalog yourself here. Please note you have auto-complete on the field members automatically; thank you TypeScript!

But, did you spot the error?

Yes, the dreaded reference-by-id: not "pine-apple", but "pineapple".

The keyof keyword to the rescue!

It took some digging, but this was one of the easier finds: I only had to figure out the correct keyword: keyof.

const productCatalog: TProductCatalog = {
    "apple": { name: "An apple", price: .5 },
    "pear": { name: "Pear", price: .6 },
    "pineapple": { name: "A pineapple - or Ananananananas as they say in Dutch", price: 2.5 },
}

type TProductId = keyof typeof productCatalog;

type TOrder = {
    customerId: string,
    orderDate: Date,
    orderLines: { productId: TProductId, quantity: number }[]
}

const order: TOrder = {
    customerId: "ToJans",
    orderDate: new Date(),
    orderLines: [
        { productId: "apple", quantity: 5 },
        { productId: "pear", quantity: 5 },
        { productId: "pine-apple", quantity: 5 },
    ]
} 

However, the type for TProductId was a string

Why was TProductId a string and not an "apple"|"pear"|"pineapple"

At first I was puzzled, but then it hit me… This makes sense because we literally declare the type of productCatalog as a Record<string,...>. which is the type parameter we pass to the keyof

Hello Captain Obvious!

So I tried removing the typing from the const, and I was thrilled!

Working autocomplete!

Try it yourself in the Playground Link

So how can we avoid this? So autocomplete/referential integrity works, but we cannot have types?

Helper functions to the rescue!

This took some digging, and apparently there is a simple workaround: use a helper function:

function defineCatalog<T extends TProductCatalog>(instance:T){
    return instance;
}

This function takes in an instance of any type T, as long as T implements the TProductCatalog

And voila, you have reference-by-value at compile time.

The end result

Try it yourself in this playground

In closing

There you have it; referential integrity checking at compile-time. While this is not exactly rocket-science, it required a little bit out-of-the-box thinking.

I might be using the type system beyond it’s initial intent, but I think this will provide a lot of value in the well-constrained low-code platform that we are building for Virtual Sales Lab.

Please note that this is still a little bit rough around the edges, especially as you start creating dependencies on other types.

I hope this was an interesting read; if I receive a lot of feedback on this I will probably write more posts about some of my other, more advanced findings, so let me know!