Advanced TypeScript Techniques - #SemanaTS day 4
- What you knew and didn't know about TS - TS Week day 1
- External types and declaration packages - #TypeScript Week day 2
- TypeScript good and bad practices - #SemanaTS day 3
- Advanced TypeScript Techniques - #SemanaTS day 4 (you are here)
- Type-level programming - #TypeScriptWeek day 5
Part 4 of 5 of the series TS Week
We have been talking a lot about types and gone through some good and bad code practices using TypeScript, but I still feel we are missing something here. That something is the less conventional techniques you’ll see around.
I don’t like calling them advanced techniques, but lacking a better name to describe them, I’ll stick with it. These techniques are nothing out of the ordinary, but they are very clever uses of the type system to make it work for you more easily. But enough introduction, let’s talk about our first technique!
Satisfies#
This is a super recent “technique”, I already talked about it here on the blog before. I wouldn’t even call it a technique because it’s a new operator, but the use of this operator is quite an interesting technique! This is satisfies.
When we are dealing with types, one of the problems that frequently occurs is that the more generic types inferred by the compiler end up overriding the more specific types we want. Let’s look at an example we already used, with 3D points:

See that calling toFixed will return an error, as it should, because we need to check if the property exists, but we can assign a non-existent value to a variable anyway, in our case x.
I want to be clear that in this specific case, assigning
xto a variable wouldn’t make much difference because TS will typeponto.xasnumber | undefined, meaning we’ll have to check it anyway.
This happens because even if we know that our object only has one of the keys, TS can’t infer this correctly because the only information we gave it was that our object could have one or two properties. But if we use the satisfies operator things change:

Now we have the correct inference, and the type of ponto will be { y: number }, meaning it’s as if the compiler took the value of your variable, went to the object you defined with satisfies, looked there, and returned to you only the properties of your variable that exist in that object.
Another quite interesting use of satisfies is to ensure that different properties have different types from a more generic type, for example, a mixed object:

See that we can’t use the toUpperCase() method because the union of string | number doesn’t have that method, even though nome is a string, but if we tell TS that this more specific object satisfies a more generic form, then we can have our correct inference:

Now, the type of carro will be { nome: string, ano: number, cor: string } instead of a Record<Props, string|number> that types all values as string | number.
Notice that in both examples, we had to remove the type annotation from the variable and pass it directly to
satisfies.
Deep inference with as const#
This case deserves to be separate because it’s one of the coolest ways to use satisfies. But you can also say that a type is a constant, using let v = 20 as const, and TS will type that value as type v = 20, a literal type.
Let’s say we have an object with routes for an API. This object has the following interface:

So far, TS’s type inference is typing this object in the following way:

If we use satisfies Rotas at the end of the object, we’ll change that inference a bit:

See that the booleans are now literals true or false. But if we have a function like this:

We can’t call our route because the literal type / is different from the more generic string type, so we’ll use as const on our object:

And now let’s look at our types again:

See that they are fully inferred as readonly and with literal types, which allows us to pass the correct value to our function.
Branded types#
Branded types are a way to validate certain data types against a certain pattern, for example, if we want to validate that a monetary value is valid, we need two pieces of information: the currency and the amount of that currency.
But each currency, despite having equal values, are completely different, we can’t pay 100 reais for something worth 100 dollars because R100. For that we have branded types.
Branded types are types that have a brand or tag, and can be defined like this:

The intersection of two types is a new type containing both properties of the other types. By default (but not required), a branded type is the union of a primitive type with an object that contains __brand or __tag (with 2 underscores).
This is a community definition, you can call your object however you want.
But notice that if you try to run this code, we’ll have an error saying we can’t assign 200 to Dolares, this is because no branded type is assignable by default, they need to be forced or checked.
We’ll talk about verification more here, but for now we can do the following workaround just so I can show you what their real use is:

Now let’s say we need to convert our currencies, for that we’ll create a function like this:

Now we have a function that only accepts reals and only returns dollars. So we guarantee that the returns from these functions are not the main primitives number but a variation of them that can’t be specified directly.
If we create a type for reals and call the function, we won’t have any errors:

This is how to work with branded types, they need to be received either as already-checked parameters, or as external data that needs to be checked and converted to a branded type.
Note: NEVER use direct type cast (with
as <type>) to create a branded type, we’ll see other safer ways to do this.
Going back to yesterday’s subject about generics, can you see a way to improve our branded type? We can use a generic to create a Branded<T,B> type:

Type guards and assertion functions#
These are the two ways we can check and create a type cast from a normal type to a branded type as I mentioned earlier. While both can be used for the same thing, assertion functions and type guards are fundamentally different.
Assertion Functions#
Assertion functions, as the name already says, are functions that ensure a passed value is of a certain type, if they aren’t, we have an error. In TypeScript, assertion functions are defined with the keyword asserts <value> is <type>:

This is a very useful example to demonstrate the power of branded types together with assertion functions. When working with databases, we need to ensure that a certain value is a UUID and not just any normal string, for that we use an assertion function like this:

See that we’re throwing an error if our regex is not valid, this guarantees that whatever needs this string later, it will be a UUID.
If you want to know what the UUID regex is that I cut because of space, it’s this one:
^_[0__-__9a__-__fA__-__F]_{8}\b-_[0__-__9a__-__fA__-__F]_{4}\b-_[0__-__9a__-__fA__-__F]_{4}\b-_[0__-__9a__-__fA__-__F]_{4}\b-_[0__-__9a__-__fA__-__F]_{12}$
Thus, we can use it in the following way:

Type Guards#
Type guards are almost exactly the same as assertion functions, but instead of throwing an error, they return a boolean guaranteeing that they are of a certain type, using the syntax <value> is <type>:

Type guards are very useful when you want to validate a type but don’t want the app to throw an error stopping execution, which is very useful, for example, if your value can be converted later to the required type.
Together with assertion functions, type guards are the safest ways to ensure that a certain type is of a certain “brand”, but of course you can also use normal functions that return the value as value as Brand, given that you’ll already do the validation before the return.
Enums#
Enumerators are present in most statically typed programming languages since forever. However, they are not yet a reality in JavaScript. TS implements the enumeration pattern, and they are not considered an advanced technique, but knowing when to use enums and when not to use them is.
There are people against and others in favor of using enums, I personally like to use them quite a bit, but we have to agree that they have disadvantages, mainly because they are the only TS construct that generates JavaScript code. That’s right, if you write an enum like this:

Unlike what TypeScript does with the rest of the types (the so-called type erasure) and erases them from the final code. This enum will be represented in the following way in real production code:

This happens because enums can be used both as types and as values:

In the function above, you can see that the parameter a is of type Animal and we call the function with the enum value. And an important detail is that we can’t use the string, meaning we couldn’t call setAnimal('Dog') because 'Dog' is not an enum value.
This isn’t a big problem, but unfortunately, when we receive values from outside like APIs and such, we have to do a type cast in some cases to satisfy the enum.
Personally, I don’t see a problem, especially because we won’t see this code or work on it manually, but many believe it’s not a good option. But what are the other options?
Disjoint unions#
Another way to do enums is to use disjoint unions, which are unions of literal types:

Here we don’t have the enum code, but we also can’t use its types anywhere, which leaves these strings scattered through the code. In a large system this can be very problematic.
You can even “solve” this by assigning each value to a constant and using the constants as parameters, but that’s not much better.
Dictionary of constants#
Another way is to create a dictionary:

It lets us use both the strings and the dictionary value, but we could pass any string there and it would also work, which is terrible.
To solve this we can use a constant dictionary of constants (complicated), basically it’s adding an as const at the end, this makes the object become a disjoint union of strings:

Constant enums#
There’s another way to declare an enum, which is a constant enum, meaning when you compile the code, all the values that were used in the enum will be placed literally in the final code, that is:

In this code, besides not being able to use the values as string (because we have an enum) the final JavaScript looks like this:

See that the enum no longer exists, except for the comment, you wouldn’t even know it’s an enum. This is great when you’re creating your own applications because there’s a notable reduction in memory and increase in speed in large projects, but the official documentation itself says they should not be shared with other projects, as we talked about on day 2 with declaration files.
The only way to share const enums is by enabling a configuration called
preserveConstEnumsthat essentially makes the const enum a normal enum.
Practice exercises#
How about we correct yesterday’s exercises? The Flatten and FlattenDeep types can be found here. The class implementation can be found here.
For today’s challenge:
- Using the type declarations we saw before, extend the native JavaScript
setTimeoutandsetIntervalmethods to return a branded type ofTimeoutIDandIntervalID, then extend theclearTimeoutandclearIntervalfunctions to accept only branded types as parameters and not just any number.
Don’t forget to leave your feedback about #SemanaTS here in this form!
Footnotes#
-
My former coworker, Robin Pokorny, has a great article about this on his blog, I’ll try to summarize this point with my opinions, but it’s worth reading. ↩