TypeScript claims to be a strongly typed programming language built on top of JavaScript, providing better tooling at any scale. However, TypeScript includes the any
type, which can often sneak into a codebase implicitly and lead to a loss of many of TypeScript's advantages.
This article explores ways to take control of the any
type in TypeScript projects. Get ready to unleash the power of TypeScript, achieving ultimate type safety and improving code quality.
Disadvantages of Using Any in TypeScript
TypeScript provides a range of additional tooling to enhance developer experience and productivity:
- It helps catch errors early in the development stage.
- It offers excellent auto-completion for code editors and IDEs.
- It allows for easy refactoring of large codebases through fantastic code navigation tools and automatic refactoring.
- It simplifies understanding of a codebase by providing additional semantics and explicit data structures through types.
However, as soon as you start using the any
type in your codebase, you lose all the benefits listed above. The any
type is a dangerous loophole in the type system, and using it disables all type-checking capabilities as well as all tooling that depends on type-checking. As a result, all the benefits of TypeScript are lost: bugs are missed, code editors become less useful, and more.
For instance, consider the following example:
function parse(data: any) {
return data.split('');
}
// Case 1
const res1 = parse(42);
// ^ TypeError: data.split is not a function
// Case 2
const res2 = parse('hello');
// ^ any
In the code above:
- You will miss auto-completion inside the
parse
function. When you typedata.
in your editor, you won't be given correct suggestions for the available methods fordata
. - In the first case, there is a
TypeError: data.split is not a function
error because we passed a number instead of a string. TypeScript is not able to highlight the error becauseany
disables type checking. - In the second case, the
res2
variable also has theany
type. This means that a single usage ofany
can have a cascading effect on a large portion of a codebase.
Using any
is okay only in extreme cases or for prototyping needs. In general, it is better to avoid using any
to get the most out of TypeScript.
Where the Any Type Comes From
It's important to be aware of the sources of the any
type in a codebase because explicitly writing any
is not the only option. Despite our best efforts to avoid using the any
type, it can sometimes sneak into a codebase implicitly.
There are four main sources of the any
type in a codebase:
- Compiler options in tsconfig.
- TypeScript's standard library.
- Project dependencies.
- Explicit use of
any
in a codebase.
I have already written articles on Key Considerations in tsconfig and Improving Standard Library Types for the first two points. Please check them out if you want to improve type safety in your projects.
This time, we will focus on automatic tools for controlling the appearance of the any
type in a codebase.
Stage 1: Using ESLint
ESLint is a popular static analysis tool used by web developers to ensure best practices and code formatting. It can be used to enforce coding styles and find code that doesn't adhere to certain guidelines.
ESLint can also be used with TypeScript projects, thanks to typesctipt-eslint plugin. Most likely, this plugin has already been installed in your project. But if not, you can follow the official getting started guide.
The most common configuration for typescript-eslint
is as follows:
module.exports = {
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
],
plugins: ['@typescript-eslint'],
parser: '@typescript-eslint/parser',
root: true,
};
This configuration enables eslint
to understand TypeScript at the syntax level, allowing you to write simple eslint rules that apply to manually written types in a code. For example, you can forbid the explicit use of any
.
The recommended
preset contains a carefully selected set of ESLint rules aimed at improving code correctness. While it's recommended to use the entire preset, for the purpose of this article, we will focus only on the no-explicit-any
rule.
no-explicit-any
TypeScript's strict mode prevents the use of implied any
, but it doesn't prevent any
from being explicitly used. The no-explicit-any
rule helps to prohibit manually writing any
anywhere in a codebase.
// ❌ Incorrect
function loadPokemons(): any {}
// ✅ Correct
function loadPokemons(): unknown {}
// ❌ Incorrect
function parsePokemons(data: Response<any>): Array<Pokemon> {}
// ✅ Correct
function parsePokemons(data: Response<unknown>): Array<Pokemon> {}
// ❌ Incorrect
function reverse<T extends Array<any>>(array: T): T {}
// ✅ Correct
function reverse<T extends Array<unknown>>(array: T): T {}
The primary purpose of this rule is to prevent the use of any
throughout the team. This is a means of strengthening the team's agreement that the use of any
in the project is discouraged.
This is a crucial goal because even a single use of any
can have a cascading impact on a significant portion of the codebase due to type inference. However, this is still far from achieving ultimate type safety.
Why no-explicit-any is Not Enough
Although we have dealt with explicitly used any
, there are still many implied any
within a project's dependencies, including npm packages and TypeScript's standard library.
Consider the following code, which is likely to be seen in any project:
const response = await fetch('https://pokeapi.co/api/v2/pokemon');
const pokemons = await response.json();
// ^? any
const settings = JSON.parse(localStorage.getItem('user-settings'));
// ^? any
Both variables pokemons
and settings
were implicitly given the any
type. Neither no-explicit-any
nor TypeScript's strict mode will warn us in this case. Not yet.
This happens because the types for response.json()
and JSON.parse()
come from TypeScript's standard library, where these methods have an explicit any
annotation. We can still manually specify a better type for our variables, but there are nearly 1,200 occurrences of any
in the standard library. It's nearly impossible to remember all the cases where any
can sneak into our codebase from the standard library.
The same goes for external dependencies. There are many poorly typed libraries in npm, with most still being written in JavaScript. As a result, using such libraries can easily lead to a lot of implicit any
in a codebase.
Generally, there are still many ways for any
to sneak into our code.
Stage 2: Enhancing Type Checking Capabilities
Ideally, we would like to have a setting in TypeScript that makes the compiler complain about any variable that has received the any
type for any reason. Unfortunately, such a setting does not currently exist and is not expected to be added.
We can achieve this behavior by using the type-checked mode of the typescript-eslint
plugin. This mode works in conjunction with TypeScript to provide complete type information from the TypeScript compiler to ESLint rules. With this information, it is possible to write more complex ESLint rules that essentially extend the type-checking capabilities of TypeScript. For instance, a rule can find all variables with the any
type, regardless of how any
was obtained.
To use type-aware rules, you need to slightly adjust ESLint configuration:
module.exports = {
extends: [
'eslint:recommended',
- 'plugin:@typescript-eslint/recommended',
+ 'plugin:@typescript-eslint/recommended-type-checked',
],
plugins: ['@typescript-eslint'],
parser: '@typescript-eslint/parser',
+ parserOptions: {
+ project: true,
+ tsconfigRootDir: __dirname,
+ },
root: true,
};
To enable type inference for typescript-eslint
, add parserOptions
to ESLint configuration. Then, replace the recommended
preset with recommended-type-checked
. The latter preset adds about 17 new powerful rules. For the purpose of this article, we will focus on only 5 of them.
no-unsafe-argument
The no-unsafe-argument
rule searches for function calls in which a variable of type any
is passed as a parameter. When this happens, type checking is lost, and all the benefits of strong typing are also lost.
For example, let's consider a saveForm
function that requires an object as a parameter. Suppose we receive JSON, parse it, and obtain an any
type.
// ❌ Incorrect
function saveForm(values: FormValues) {
console.log(values);
}
const formValues = JSON.parse(userInput);
// ^? any
saveForm(formValues);
// ^ Unsafe argument of type `any` assigned
// to a parameter of type `FormValues`.
When we call the saveForm
function with this parameter, the no-unsafe-argument
rule flags it as unsafe and requires us to specify the appropriate type for the value
variable.
This rule is powerful enough to deeply inspect nested data structures within function arguments. Therefore, you can be confident that passing objects as function arguments will never contain untyped data.
// ❌ Incorrect
saveForm({
name: 'John',
address: JSON.parse(addressJson),
// ^ Unsafe assignment of an `any` value.
});
The best way to fix the error is to use TypeScript’s type narrowing or a validation library such as Zod or Superstruct. For instance, let's write the parseFormValues
function that narrows the precise type of parsed data.
// ✅ Correct
function parseFormValues(data: unknown): FormValues {
if (
typeof data === 'object' &&
data !== null &&
'name' in data &&
typeof data['name'] === 'string' &&
'address' in data &&
typeof data.address === 'string'
) {
const { name, address } = data;
return { name, address };
}
throw new Error('Failed to parse form values');
}
const formValues = parseFormValues(JSON.parse(userInput));
// ^? FormValues
saveForm(formValues);
Note that it is allowed to pass the any
type as an argument to a function that accepts unknown
, as there are no safety concerns associated with doing so.
Writing data validation functions can be a tedious task, especially when dealing with large amounts of data. Therefore, it is worth considering the use of a data validation library. For instance, with Zod, the code would look like this:
// ✅ Correct
import { z } from 'zod';
const schema = z.object({
name: z.string(),
address: z.string(),
});
const formValues = schema.parse(JSON.parse(userInput));
// ^? { name: string, address: string }
saveForm(formValues);
no-unsafe-assignment
The no-unsafe-assignment
rule searches for variable assignments in which a value has the any
type. Such assignments can mislead the compiler into thinking that a variable has a certain type, while the data may actually have a different type.
Consider the previous example of JSON parsing:
// ❌ Incorrect
const formValues = JSON.parse(userInput);
// ^ Unsafe assignment of an `any` value
Thanks to the no-unsafe-assignment
rule, we can catch the any
type even before passing formValues
elsewhere. The fixing strategy remains the same: We can use type narrowing to provide a specific type to the variable's value.
// ✅ Correct
const formValues = parseFormValues(JSON.parse(userInput));
// ^? FormValues
no-unsafe-member-access and no-unsafe-call
These two rules trigger much less frequently. However, based on my experience, they are really helpful when you are trying to use poorly typed third-party dependencies.
The no-unsafe-member-access
rule prevents us from accessing object properties if a variable has the any
type, since it may be null
or undefined
.
The no-unsafe-call
rule prevents us from calling a variable with the any
type as a function, as it may not be a function.
Let's imagine that we have a poorly typed third-party library called untyped-auth
:
// ❌ Incorrect
import { authenticate } from 'untyped-auth';
// ^? any
const userInfo = authenticate();
// ^? any ^ Unsafe call of an `any` typed value.
console.log(userInfo.name);
// ^ Unsafe member access .name on an `any` value.
The linter highlights two issues:
- Calling the
authenticate
function can be unsafe, as we may forget to pass important arguments to the function. - Reading the
name
property from theuserInfo
object is unsafe, as it will benull
if authentication fails.
The best way to fix these errors is to consider using a library with a strongly typed API. But if this is not an option, you can augment the library types yourself. An example with the fixed library types would look like this:
// ✅ Correct
import { authenticate } from 'untyped-auth';
// ^? (login: string, password: string) => Promise<UserInfo | null>
const userInfo = await authenticate('test', 'pwd');
// ^? UserInfo | null
if (userInfo) {
console.log(userInfo.name);
}
no-unsafe-return
The no-unsafe-return
rule helps to not accidentally return the any
type from a function that should return something more specific. Such cases can mislead the compiler into thinking that a returned value has a certain type, while the data may actually have a different type.
For instance, suppose we have a function that parses JSON and returns an object with two properties.
// ❌ Incorrect
interface FormValues {
name: string;
address: string;
}
function parseForm(json: string): FormValues {
return JSON.parse(json);
// ^ Unsafe return of an `any` typed value.
}
const form = parseForm('null');
console.log(form.name);
// ^ TypeError: Cannot read properties of null
The parseForm
function may lead to runtime errors in any part of the program where it is used, since the parsed value is not checked. The no-unsafe-return
rule prevents such runtime issues.
Fixing this is easy by adding validation to ensure that the parsed JSON matches the expected type. Let's use the Zod library this time:
// ✅ Correct
import { z } from 'zod';
const schema = z.object({
name: z.string(),
address: z.string(),
});
function parseForm(json: string): FormValues {
return schema.parse(JSON.parse(json));
}
A Note About Performance
Using type-checked rules comes with a performance penalty for ESLint since it must invoke TypeScript's compiler to infer all the types. This slowdown is mainly noticeable when running the linter in pre-commit hooks and in CI, but it is not noticeable when working in an IDE. The type checking is performed once on IDE startup and then updates the types as you change the code.
It is worth noting that just inferring the types works faster than the usual invocation of the tsc
compiler. For example, on our most recent project with about 1.5 million lines of TypeScript code, type checking through tsc
takes about 11 minutes, while the additional time required for ESLint's type-aware rules to bootstrap is only about 2 minutes.
For our team, the additional safety provided by using type-aware static analysis rules is worth the tradeoff. On smaller projects, this decision is even easier to make.
Conclusion
Controlling the use of any
in TypeScript projects is crucial for achieving optimal type safety and code quality. By utilizing the typescript-eslint
plugin, developers can identify and eliminate any occurrences of the any
type in their codebase, resulting in a more robust and maintainable codebase.
By using type-aware eslint rules, any appearance of the keyword any
in our codebase will be a deliberate decision rather than a mistake or oversight. This approach safeguards us from using any
in our own code, as well as in the standard library and third-party dependencies.
Overall, a type-aware linter allows us to achieve a level of type safety similar to that of statically typed programming languages such as Java, Go, Rust, and others. This greatly simplifies the development and maintenance of large projects.
I hope you have learned something new from this article. Thank you for reading!
Top comments (0)