Item 19: Avoid Cluttering Your Code with Inferable Types

Chapter 3 of Effective TypeScript covers type inference: the process by which TypeScript infers the type of symbols in the absence of explicit annotations. A significant fraction of the comments I leave on TypeScript code reviews point out places where type annotations are unnecessary and can be omitted. This item explains why explicitly annotating inferable types is typically a bad idea, and enumerates a few specific exceptions to this rule.

The first thing that many new TypeScript developers do when they convert a codebase from JavaScript is fill it with type annotations. TypeScript is about types, after all! But in TypeScript many annotations are unnecessary. Declaring types for all your variables is counterproductive and is considered poor style.

Don’t write:

let x: number = 12;

Instead, just write:

let x = 12;

If you mouse over x in your editor, you’ll see that its type has been inferred as number:

The explicit type annotation is redundant. Writing it just adds noise. If you're unsure of the type, you can check it in your editor.

TypeScript will also infer the types of more complex objects. Instead of:

const person: {
name: string;
born: {
where: string;
when: string;
};
died: {
where: string;
when: string;
}
} = {
name: 'Sojourner Truth',
born: {
where: 'Swartekill, NY',
when: 'c.1797',
},
died: {
where: 'Battle Creek, MI',
when: 'Nov. 26, 1883'
}
};

you can just write:

const person = {
name: 'Sojourner Truth',
born: {
where: 'Swartekill, NY',
when: 'c.1797',
},
died: {
where: 'Battle Creek, MI',
when: 'Nov. 26, 1883'
}
};

Again, the types are exactly the same. Writing the type in addition to the value just adds noise here. (Item 21, Understand Type Widening, has more to say on the types inferred for object literals.)

What's true for objects is also true for arrays. TypeScript has no trouble figuring out the return type of this function based on its inputs and operations:

function square(nums: number[]) {
return nums.map(x => x * x);
}
const squares = square([1, 2, 3, 4]); // Type is number[]

TypeScript may infer something more precise than what you expected. This is generally a good thing. For example:

const axis1: string = 'x';  // Type is string
const axis2 = 'y'; // Type is "y"

"y" is a more precise type for the axis variable, and using it may fix some errors that would appear with the less-precise string.

Allowing types to be inferred can also facilitate refactoring. Say you have a Product type and a function to log it:

interface Product {
id: number;
name: string;
price: number;
}

function logProduct(product: Product) {
const id: number = product.id;
const name: string = product.name;
const price: number = product.price;
console.log(id, name, price);
}

At some point you learn that product IDs might have letters in them in addition to numbers. So you change the type of id in Product. Because you included explicit annotations on all the variables in logProduct, this produces an error:

interface Product {
id: string;
name: string;
price: number;
}

function logProduct(product: Product) {
const id: number = product.id;
// ~~ Type 'string' is not assignable to type 'number'
const name: string = product.name;
const price: number = product.price;
console.log(id, name, price);
}

Had you left off all the annotations in the logProduct function body, the code would have passed the type checker without modification.

A better implementation of logProduct would use destructuring assignment:

function logProduct(product: Product) {
const {id, name, price} = product;
console.log(id, name, price);
}

This version allows the types of all the local variables to be inferred. The corresponding version with explicit type annotations is repetitive and cluttered:

function logProduct(product: Product) {
const {id, name, price}: {id: string; name: string; price: number } = product;
console.log(id, name, price);
}

Explicit type annotations are still required in some situations where TypeScript doesn’t have enough context to determine a type on its own. You have seen one of these before: function parameters.

Some languages will infer types for parameters based on their eventual usage, but TypeScript does not. In TypeScript, a variable's type is generally determined when it is first introduced.

Ideal TypeScript code includes type annotations for function/method signatures but not for the local variables created in their bodies. This keeps noise to a minimum and lets readers focus on the implementation logic.

There are some situations where you can leave the type annotations off of function parameters, too. When there’s a default value, for example:

function parseNumber(str: string, base=10) {
// ...
}

Here the type of base is inferred as number because of the default value of 10.

Parameter types can usually be inferred when the function is used as a callback for a library with type declarations. The declarations on request and response in this example using the express HTTP server library are not required:

// Don't do this:
app.get('/health', (request: express.Request, response: express.Response) => {
response.send('OK');
});

// Do this:
app.get('/health', (request, response) => {
response.send('OK');
});

For much more on this, see Item 26: Understand How Context Is Used in Type Inference.

There are a few situations where you may still want to specify a type even where it can be inferred.

One is when you define an object literal:

const elmo: Product = {
name: 'Tickle Me Elmo',
id: '048188 627152',
price: 28.99,
};

When you specify a type on a definition like this, you enable excess property checking. This can help catch errors, particularly for types with optional fields. (This is discussed in more detail in Item 11: Recognize the Limits of Excess Property Checking.)

You also increase the odds that an error will be reported in the right place. If you leave off the annotation, a mistake in the object's definition will result in a type error where it's used, rather than where it's defined:

const furby = {
name: 'Furby',
id: 630509430963,
price: 35,
};
logProduct(furby);
// ~~~~~ Argument .. is not assignable to parameter of type 'Product'
// Types of property 'id' are incompatible
// Type 'number' is not assignable to type 'string'

With an annotation, you get a more concise error in the place where the mistake was made:

 const furby: Product = {
name: 'Furby',
id: 630509430963,
// ~~ Type 'number' is not assignable to type 'string'
price: 35,
};
logProduct(furby);

Similar considerations apply to a function's return type. You may still want to annotate this even when it can be inferred to ensure that implementation errors don't leak out into uses of the function.

Say you have a function which retrieves a stock quote:

function getQuote(ticker: string) {
return fetch(`https://quotes.example.com/?q=${ticker}`)
.then(response => response.json());
}

You decide to add a cache to avoid duplicating network requests:

const cache: {[ticker: string]: number} = {};
function getQuote(ticker: string) {
if (ticker in cache) {
return cache[ticker];
}
return fetch(`https://quotes.example.com/?q=${ticker}`)
.then(response => response.json())
.then(quote => {
cache[ticker] = quote;
return quote;
});
}

There’s a mistake in this implementation: you should really be returning Promise.resolve(cache[ticker]) so that getQuote always returns a Promise. The mistake will most likely produce an error… but in the code that calls getQuote, rather than in getQuote itself:

const cache: {[ticker: string]: number} = {};
function getQuote(ticker: string): Promise<number> {
if (ticker in cache) {
return cache[ticker];
// ~~~~~~~~~~~~~ Type 'number' is not assignable to 'Promise<number>'
}
// ...
}

When you annotate the return type, it keeps implementation errors from manifesting as errors in user code. (async functions are an effective way to avoid this specific error with Promises. They're discused in detail in Item 25: Use async Functions Instead of Callbacks for Asynchronous Code).

Writing out the return type may also help you think more clearly about your function: you should know what its input and output types are before you implement it. While the implementation may shift around a bit, the function's contract (its type signature) generally should not. This is similar in spirit to test-driven development (TDD), in which you write the tests that exercise a function before you implement it. Writing the full type signature first helps get you the function you want, rather than the one the implementation makes expedient.

A final reason to annotate return values is if you want to use a named type. You might choose not to write a return type for this function, for example:

interface Vector2D { x: number; y: number; }
function add(a: Vector2D, b: Vector2D) {
return { x: a.x + b.x, y: a.y + b.y };
}

TypeScript infers the return type as { x: number; y: number; }. This is compatible with Vector2D, but it may be surprising to users of your code when they see Vector2D as a type of the input and not of the output:

If you annotate the return type, the presentation is more straightforward. And if you've written documentation on the type (Item 48: Use TSDoc for API Comments) then it will be associated with the returned value as well. As the complexity of the inferred return type increases, it becomes increasingly helpful to provide a name.

If you are using a linter, the eslint rule no-inferrable-types (note the variant spelling) can help ensure that all your type annotations are really necessary.

Things to Remember

  • Avoid writing type annotations when TypeScript can infer the same type.
  • Ideally your code has type annotations in function/method signatures but not on local variables in their bodies.
  • Consider using explicit annotations for object literals and function return types even when they can be inferred. This will help prevent implementation errors from surfacing in user code.
Like this post? Consider subscribing to my newsletter, the RSS feed, or following me on Twitter.
comments powered by Disqus
Effective TypeScript Book Cover

Effective TypeScript shows you not just how to use TypeScript but how to use it well. The book's 62 items help you build mental models of how TypeScript and its ecosystem work, make you aware of pitfalls and traps to avoid, and guide you toward using TypeScript’s many capabilities in the most effective ways possible. Regardless of your level of TypeScript experience, you can learn something from this book.

After reading Effective TypeScript, your relationship with the type system will be the most productive it's ever been! Learn more »