Enforcing UX with TypeScript
By Typing Google’s Unit Converter
You’ve used Google’s unit converter right?
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.
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?
- stay the same so it’ll now read 100 kg?
- 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?
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:
Finally the basic form,
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.
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.
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) type and create types like
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.
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
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
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.
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
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
We’ll use a switch statement.
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.
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
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:
Playing with the converter, you’ll notice three UX rules:
- Changing the input unit or output unit recalculates/invalidates the output value
- The input value and output value invalidate each other, i.e., when you change one, it forces the other to change
- 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:
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.
What about UX Rule 2? How do we lock the
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.
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:
Now we can rebuild the form.
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
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.
switch statements, TypeScript’s poor code analysis isn’t able to infer that
curFormVal.outputUnit would never be the same value:
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
That’s all! Playground Link.