Should you annotate or let TypeScript infer the types?
Lately, I've been thinking about how one can decide whether to annotate or let TypeScript infer the types of variables/expressions whenever it can. If you don't know what Type Inference is, refer to the official guide.
If you're using Flow, this article will still make sense, but TypeScript and Flow have different type inference capabilities. For example, Flow has non-local type inference while TypeScript doesn't.
When using TypeScript, it's not really obvious how to decide whether to annotate or let your types be inferred (when TS can in fact infer them). At first sight, it's easy to think that it doesn't make a difference at all. However, I think it's a little bit more nuanced than it may seem.
Let's start by looking at 2 ways of writing the same code:
// declare function fetchUser(id: string): Promise<User>;
function exportUserInfo(id: string): Promise<void> {
return fetchUser(id).then((user: User) => {
export({ format: "JSON", data: user });
});
}
// declare function fetchUser(id: string): Promise<User>;
function exportUserInfo(id: string) {
return fetchUser(id).then(user => {
export({ format: "JSON", data: user });
}
}
In the first snippet, we annotate both the return type of the exportUserInfo
function as well as the type of the user
variable in the fetchUser
promise handler. In the second snippet, we don't annotate either of these variables.
Both snippets are valid TypeScript and they represent two different coding styles. The reason they're both valid TypeScript is because all the types of all the variables/expressions we didn't annotate can be correctly inferred by TypeScript.
The first coding style is considered to be more "verbose" (which can be a good thing or a bad thing), since the code is very explicit about the types of all the variables/expressions. Based on my experience, TypeScript (and Flow) beginners will tend to favor the first style and as engineers get more experienced, they start to favor the second style. I think that both styles make sense, in different circumstances.
In this article, I want to look at a few factors to keep in mind when deciding between annotating or not annotating.
Where will you get the type error?
Let's say we're declaring an object of type Person
but not annotating it and then passing it to a function that expects an object of type Person
:
declare function printPerson(p: Person): void;
type Person = { name: string; };
const p = { name: 7 }:
printPerson(p); // Error: cannot call `printPerson` with `{ name: number }`
In this case, TypeScript will give us an error on the last line, when trying to call the printPerson
function. If we had tried to annotate p
as Person
( const p: Person = { ... }
), we would have gotten an error on the declaration line instead. The error would be slightly more obvious though — something like Object of type { name: number } doesn't match the Person type
.
This means that most of the time, I recommend annotating object literals (especially if they'll be passed to more than one function), since it'll likely save you time when dealing with errors.
Refactoring types and git blame
Types, just like variables, are often renamed and refactored. Let's say we have some code like:
fetchUser((user: User) => {
return user;
});
If we now rename the User
type to UserInfo
, we'll have to fix the annotation. This is annoying and will effectively break git blame
. This means that annotating types comes with a maintenance cost. Letting TS infer will give you more freedom when refactoring types and it makes your code more flexible. This is the main reason why most of the time I recommend against annotating loose variables or callback/promise arguments.
For similar reasons, whenever you only depend on a couple of properties from an object, not naming the object type can be a good idea. Let's say we have a type File
that looks like this:
type File = {
baseName: string;
name: string;
// ...
};
And we want to write a function that gets the full path of the file. We could annotate the argument of the getFullPath
function as File
or make it unnamed:
function getFullPath({ basePath, name }: { basePath: string, name: string }) {
return `${basePath}/${name}`;
}
console.log(getFullPath(new File("/home/dir", "file.txt"));
The getFullPath
function can now be used with files or folders, provided that both the types File
and Folder
have a baseName
and a name
field. This example may seem a bit contrived but some variations of it been very useful to me in the past.
Being able to focus on the code/verbosity
Code is much more often read than written, so it should be as readable as possible. Sometimes, this means annotating to make things more explicit. Other times, this means letting TS infer to make things less verbose. While this is very subjective, striking the balance is key. As an example, here's a snippet of code that I consider to be over-annotated:
type EnvironmentType = "DEVELOPMENT" | "PRODUCTION";
export const DEFAULT_LIMIT: number = 50;
function getLimit(env: EnvironmentType): number {
if (NODE_ENV === "DEVELOPMENT") {
return 10;
} else {
return DEFAULT_LIMIT;
}
}
callback((
user: User,
envType: EnvironmentType,
connection: Connection,
error?: Error
) => {
// ...
const limit: number = getLimit(env);
// ...
});
I personally think the code would be more readable without any of the annotations in this file. All of the variables have very obvious names and matching types and so the type annotations can be a distraction when reading the code. Once again, I'll highlight that this is highly subjective.
Learning the ins and outs of TypeScript
If you're learning TypeScript, in general, I'd recommend leaning towards annotating less. Then, use your text editor to see what TypeScript is inferring as you write your code (more on this below). Understanding what TypeScript can and can't infer will make you more comfortable with TypeScript. If you think you should annotate something based on other guidelines in this article, then go for it. However, it's probably worth it to learn whether TS can infer it on its own or not before you make the decision to annotate.
Bonus Section: Editor Tweaks
If you're using VSCode, there's two things I highly recommend to make all of this easier:
#1 Keybind to hover type definition
Add this keybinding to your VS Code config to use Ctrl+M to see the (inferred) type definition of any expression.
{
"key": "ctrl+m",
"command": "editor.action.showHover",
"when": "editorTextFocus"
}
#2 Inline-types extension
If you're using VS Code, this extension will show you the inferred types for all variables and expressions inline:
Note that it's very likely that other editors have similar capabilities.
#3 noImplicitAny
If you don't have noImplicitAny
set to true
in your TypeScript configuration, TypeScript could be inferring variables or expressions to any
. I highly recommend setting noImplicitAny
to true
for a much safer TypeScript experience.
Conclusion — Defining Boundaries
At the end of the day, I think the best general guideline for whether to annotate code or not is whether the expression/variable you're annotating is a "boundary" or not. Boundaries usually refer to the exported functions/classes/etc. of your various modules. For these, you likely want to be explicit as to what you're expecting to make your APIs more clear. However, functions and variables internal to a specific module likely don't need to be annotated.
Moreover, I try to accept both code styles when doing code review since all of this is subjective and bikeshedding it won't make much of a difference. At the same time, I tend to annotate my code very little. By not annotating, readers of the code can choose how verbose they want the code to look by using (or not using) extensions such as the one linked above to see the types of expressions on their text editors.