Enforcing UX with TypeScript

By Typing Google’s Unit Converter

Steven
10 min readApr 28, 2022
Which is heavier, a pound of gold or 0.45359237 kg of feathers? (Illustration by me)

You’ve used Google’s unit converter right?

This thing.

But have you really played around with it? There’s a lot of UX (user experience) rules/business logic built into it. It’s definitely not a simple form.

For example, try updating the input unit to match the output unit. It’ll flip the 2 units! You’ll also notice that updating either the input unit or output unit will only recalculate the output value.

In this article, we’re going to explore ways to help enforce this type of UX or business logic using TypeScript.

Simpler Example

We’ll first start with a simpler example.

Let’s say that we have an app that stores a user’s weight, maybe it’s a food tracking app.

In the profile page there might be a weight field and it might look something like this:

This is very similar to the unit converter.

Suppose there was already a value inside the amount field of 100 lbs. and I decide to start tracking my weight in kilograms. If I switch over to kilograms, what should happen to the amount?

Should it…

  1. stay the same so it’ll now read 100 kg?
  2. convert the 100 lbs. to 45.3592 kg?

For this exercise we’re going to go with option 2, if I select kilograms, it should convert the value.

So, how can we reinforce this UX decision?

Basic Form

To start we can define some basic form fields:

A Basic Number Field

Just a simple <input type="number" /> component. The reason for the generic will become apparent later.

A Basic Select Field

Next, we’ll define some basic units we’ll support as well as initial options for the <Select /> field:

Units

Finally the basic form,

Form

Synthesis

In the form’s current state, we’ve achieved the first UX option where we do nothing. The user can change the unit and amount freely.

How can we implement UX option 2?

Of course we can just update our change handlers to convert the value whenever we update the unit. But there’s no checks in place to ensure that we actually did that conversion.

If code was haphazardly deleted, there would be nothing to catch that erasure. And what if we forgot to handle converting between specific units?

How can we begin to add this type of check?

Valid and Invalid States

Let’s take a page from State Machines. The idea of valid states and transitions.

https://www.trccompsci.online/mediawiki/index.php/Finite_State_Machines

This is a state machine for a door. A door can exist in 3 valid states: open, closed, and locked, and actions can be taken to transition between valid states.

States and Transitions

Our initial state has the amount field set as 100 and the unit field as lbs.
This is a valid state.

If we update the unit field to kg, that would be a transition, and if we don’t touch the 100 amount, we can treat the result of 100 kg as an invalid state.

We can say that a valid state would be if we updated the 100 amount to 45.3592 (100 lbs. in kg).

We need to somehow lock the unit to the amount. The initial state isn’t just 100 and lbs. it’s 100 lbs. The number and unit are glued together.
The number isn’t just a number.

Branding/Tagging

In the real world, numbers are usually associated with some modifier like lbs. or USD. And if you have a variable that should be in USD, you wouldn’t want to assign a Euro amount to that variable.

In nominally typed languages like Java or C#, you can extend the number (or Number) type and create types like USD and Euro that are not interoperable, even though under the hood, they’re basically still numbers.

You can’t do this in a structurally typed language such as TypeScript. We can’t extend the number type in TypeScript.

But we can simulate it with “Branding” or “Tagging”. I won’t go into details on this topic in this article, you’d have to read my other article on it but ultimately we’ll just need to define this utility:

With this we can lock the amount and unit together by branding the amount with the unit’s type.

Valid States

So we can tag our numbers with units, but how can we use this to define valid states?

Currently our form value type is defined like this:

Following that format we can define a valid state like this:

This forces the amount and unit to be the same, but since the Unit parameter extends Units, a parameter like Units.Kilogram | Units.Pound can be passed in producing something like this:

This is actually wrong because we can end up with a mismatch between amount and unit, what we actually want is:

It would be cumbersome to build this by hand, so we’ll abuse conditional types to build this for us like so:

Designing our form value using this discriminated union will force us to ensure that we convert the amount when we change the unit.

Fixing Errors with Our Handlers

Great! But things are going to get a bit hairy now. We’re going to meet a limitation in TypeScript and it’ll make things… ugly

As expected, our prior handlers are now invalid:

Let’s fix this starting with the amount handler.

Amount Handler

This one is sort of easy. If you think about it, when the amount changes, we don’t really need to do anything. It’s Measurement type should still match the unit type.

However, there’s no way to structure the code so that TypeScript knows that.

Instead, TypeScript is going to widen the incoming change:

The code flow analysis just isn’t smart enough to infer that it should always be OK.

To handle this we need to generate a value that matches our form value’s type, so we want to achieve the reverse of what we’ve been doing so far. Instead of explicitly defining the type of a value to be a discriminated union, we want to generate a value that is inferred to be a discriminated union.

There’s only 2 (or 3) ways to generate a discriminated union, using if checks or using switch statements.

We’ll use a switch statement.

Resolved.

If we want we can also use a type guard like this:

Again, this type guard is OK because we’re OK with just checking that the amount value is a number, we don’t really need to check that anything else has happened or convert anything.

Unit Handler

Ok this one is the main meat.

A universal converter will be immensely helpful in this case for us so let’s define one:

With that converter we can just do this:

Now you might be thinking, wow, what’s the point of the switch statement then? And I would agree with you. Unfortunately, without the switch statement, TypeScript won’t infer a correct union type. It’ll widen it into the bad type:

And we’re done! Playground Link

Unit Converter

Now for the unit converter. Now this is a lot more complicated.

Google’s unit converter has 4 fields. For our purposes we’ll call them:
input, inputUnit, output, outputUnit.

Playing with the converter, you’ll notice three UX rules:

  1. Changing the input unit or output unit recalculates/invalidates the output value
  2. The input value and output value invalidate each other, i.e., when you change one, it forces the other to change
  3. Trying to change the input unit to the output unit, i.e., making them the same, will flip them instead, they are never allowed to be the same

We’ll definitely tackle 1 and 2. Rule 3 we’ll skip for now but it can be managed through types.

Redefining the Form Value Type

First let’s add more unit types to make things a bit more interesting:

Unit Type

In the converter, when the input or output units change, the output value needs to be invalidated. So we need to somehow make it so that changing either will invalidate the input.

To do this we actually just need to double brand the output value. We’ll create a new branding to do that:

Then our valid state, SingleFormValue, will look like this:

Notice how the output has it’s own unit, as well as an “origin” unit that tracks the unit it was converted from. This locks the input unit to the output value. We actually don’t need to brand the input but it would still be good if this Form was connected to something like an API.

Value Type

What about UX Rule 2? How do we lock the input and output together so that if one of them changes it would invalidate the other?

That’s right! Branding.

We’ll create a “version” brand for this. So when say, the output changes, it’ll be of a different version from the existing input value and therefore no longer compatible.

Form

Our complete Form Value Type is now:

Notice that inside ValidFormValue we make sure to prevent the InputUnit from matching OutputUnit. Testing this shows that it works properly:

Synthesis

Now we can rebuild the form.

Converters

First, it would be very useful to have a universal converter that can convert both ways and create a valid form type:

Here we use function overloading to help guide TypeScript into being able to infer the proper return types. Notice that we enforce that the From and To unit types must be different. If we didn’t do that we wouldn’t be able to get away with such little code.

Unit Converter Form

Here’s our simple 4 field form:

Input/Output Value Change Handlers

To build the change handlers, first we’ll build a general purpose recalculation function.

Without the switch statements, TypeScript’s poor code analysis isn’t able to infer that curFormVal.inputUnit and curFormVal.outputUnit would never be the same value:

With the recalc utility doing the heavy lifting our change handlers become tiny:

Input/Output Unit Change Handlers

Both handlers will be fairly large hunks of switch statements, and we’ll also have to make sure that the units never match:

For the input unit:

And the output unit:

Slot all the handlers in and we’re done!

Handling the Switcheroo (UX Rule 3)

So there was that third UX rule, where in the case that the input or output unit was changed to become the same, they would flip instead. The types we have are already equipped to block that possibility which is why we needed to check that newVal !== curFormVal.inputUnit!

With the current code we return the existing form value when the units get set to the same value. What we can do instead is build a new form value that flips the units and use it instead of just returning curFormVal:

That’s all! Playground Link.

--

--