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:
{
"compilerOptions": {
"noImplicitAny": true,
"strict": true
}
}
Let's look at some examples using this config.
Working with an API
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:
async function request(endpoint: string) {
const response = await fetch(`https://api.com/v1${endpoint}`);
return response.json();
}
Here's a function to fetch a user and return their name:
async function getUserName(userId: number) {
const user = await request(`/users/${userId}`);
return user.name;
}
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:
error Missing return type on function
Let's fix it:
type User = { name: string };
async function request(endpoint: string): Promise<User> {
const response = await fetch(`https://api.com/v1${endpoint}`);
return response.json();
}
async function getUserName(userId: number): Promise<string> {
const user = await request(`/users/${userId}`);
return user.name;
}
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:
type UserRole = { type: "admin" | "staff" };
async function getUserRoles(userId: number): Promise<UserRole[]> {
return request(`/users/${userId}/roles`);
}
This won't compile because request()
was defined as always
returning a User
object which was only true for the other endpoint.
What can we do?
We could say that request()
returns Promise<any>
, right? Let's try it:
async function request(endpoint: string): Promise<any> {
const response = await fetch(`https://api.com/v1${endpoint}`);
return response.json();
}
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:
error Unexpected any. Specify a different type
Defining a custom generic
To ditch any
and safely call request()
on endpoints of differing types,
we need to give it a type variable. We'll call it Data
:
async function request<Data>(endpoint: string): Promise<Data> {
const response = await fetch(`https://api.com/v1${endpoint}`);
return response.json();
}
The <Data>
part lets us say the /users/:id
endpoint (but not others) returns a Promise resolving to a User
:
type User = { name: string };
async function getUserName(userId: number): Promise<string> {
const user = await request<User>(`/users/${userId}`);
return user.name;
}
This now makes it safe to access user.name
and would allow
us to easily
change it to user.firstName
or anything else in the future
if we need to.
Let's rewrite the other one:
type UserRole = { type: "admin" | "staff" };
async function getUserRoles(userId: number): Promise<UserRole[]> {
return request<UserRole[]>(`/users/${userId}/roles`);
}
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:
async function request<
D extends { BodyType: undefined | {}; ResponseType: {} }
>(
method: "GET" | "POST",
endpoint: string,
body?: D["BodyType"]
): Promise<D["ResponseType"]> {
const response = await fetch(`https://api.com/v1${endpoint}`, {
method,
body: body ? JSON.stringify(body) : undefined
});
return response.json();
}
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
.
Let's define a function to make a GET
request:
type User = { name: string };
async function getUser(userId: number): Promise<User> {
return request<{ BodyType: undefined; ResponseType: User }>(
"GET",
`/users/${userId}`
);
}
This defines D
as an object where BodyType
is undefined
(GET
requests typically don't have them) and ResponseType
is User
.
Here's a function that makes a POST
request:
async function createUser(user: User): Promise<User> {
return request<{ BodyType: User; ResponseType: User }>(
"POST",
"/users",
user
);
}
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.