TypeScript
makes JavaScript safer and even opens up new
architectural possibilities such as
better unit tests.
I highly recommend it.
However, the default configuration encourages unsafe patterns
that developers might not be aware of.
Specifically, I wanted to illustrate the dangers of
any
and show how you can define custom
generics
instead.
I'll also offer some lint rules that will help your team
write safer TypeScript.
Tightening up the config
For starters, a config
with at least
these options will buy you some safety:
Imagine you're working with a RESTful API
in TypeScript and you want to create a helper function that calls
fetch().
It will take a string endpoint and return a Promise for the JSON result:
This compiles without any errors. Wait, what?!
You didn't define a type for user so how does the compiler know it has a name attribute?
What happens if the API renames it
to user.firstName and you want to be sure you've updated all your code?
TypeScript aims to
preserve
runtime behavior of all JavaScript code
which, I guess, is why many things are unsafe by default.
Let's add more type safety.
Enforcing return types
The two functions above don't define return types which means TypeScript
inferred
them.
In the case of calling response.json(), the return value was typed as
Promise<any>
which is where
all type safety went out the window.
TypeScript lets you call user.name because user is of type
any
so, whatever, man.
You can begin protecting against this by configuring a linter with the
explicit-function-return-type
rule.
This will make the above code fail linting with something like:
This forced us to define a User type so that the compiler knows user.name
is a valid property.
Type inferrence is a nice feature but I came to regret not
enforcing explicit function types in a recent application I worked on.
It was convenient but I can think of several production bugs that
would have been caught by it.
It's worth the extra effort.
I see diamonds! <>
Yep, I snuck some generics
into the previous example.
The type definition Promise<string> uses the generic type,
Promise,
to say getUserName() returns a Promise resolving to a string type.
I'll show examples of defining custom generics in a minute.
The case for generics
Let's say you need another function to call a different endpoint,
this time to fetch user roles:
Yep, that compiles. Huh. Now we're back to the first problem where any
lets us do anything without type safety.
The most dangerous part is that any crept into the code and
made everything unsafe but there was no error.
A code reviewer might even miss it.
Enhancing safety by disallowing any
any is a dangerous drug
so let's quit cold turkey
by adding the
no-explicit-any
lint rule.
This wouldn't catch how response.json() is defined internally by
TypeScript to return
Promise<any>
but at least it will catch any
within our own code, showing something like:
This says request() will return a Promise resolving
to a UserRole array and
adds all of the same safety benefits.
A more complex generic
So far our request() helper deals with GET requests but what if
we add support for POST, which introduces a request body?
We'll need an additional type variable, something like
request<ResponseType, BodyType>(...)
or, more typically, request<R, B>(...) (you'll see a lot of single letter type variables in the wild).
Instead, I prefer
to define the type variable as an object with meaningful keys since
it makes the calling code easier to read.
Here's a new definition of request() that supports both GET and POST requests:
This required a bit more code and we now have a
type constraint
(via the extends keyword)
to denote the object shape.
Specifically, we're saying request() takes a type variable D which
is an object having the keys BodyType and ResponseType.
This defines D where both BodyType and ResponseType are a User.
Bolt-on solutions
I've shown how out of the box TypeScript isn't very safe and presented
some techniques for making it safer.
I also suggest hunting for third party libraries
that seek to specifically address type safety, as there are quite a few.
One in particular,
io-ts
helps make fetch() and other common I/O patterns type safe.
When working in
Redux,
typesafe-actions
adds type safety to actions as well as reducers, for example.
The case for any types
Hopefully by now I've convinced you not to build a TypeScript app
with any types but there are still valid cases for them.
If you're converting a legacy code base to TypeScript,
falling back on any is a powerful way
to maintain forward momentum.
Converting a large code base could take longer than you think.
It's a good investment but it still takes time away from shipping
product features so one often has to do it incrementally.
There are other powerful
escape hatches
in TypeScript
like @ts-ignore and @ts-nocheck.
I'm a big fan of
spike, test, break, fix
development.
During the spike phase, it's super helpful to let
the TypeScript compiler guide you.
However, you may want to ignore type errors in things like
test files while experimenting with architectural changes.
Using @ts-nocheck in test files (just temporarily)
is really handy.
Ship with confidence
The TypeScript learning curve steepens when approaching
generics but hopefully this gives someone a boost.
I showed how to add type safety to a flexible function
and call it in different ways.
Check out TypeScript's docs on
generics
for more details.
I also provided some lint rules to help keep a codebase safe as it evolves.
All of this has to be enforced with
continuous integration
for it to add lasting value.
If you'd like to play around with these code examples,
I've made them
available.
However, they are just examples.
Rather than building a type safe API client from scratch,
check out
axios.
It supports TypeScript and is
designed for type safety.
To go further, here is a
deep dive
into TypeScript that was written to address
many common problems people run into when getting started.