Making TypeScript Truly "Strongly Typed"
Tags: typescript, frontend, webdev, node
Published: 2023-09-12 06:50:00

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:

```tsx
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 type `data.` in your editor, you won't be given correct suggestions for the available methods for `data`.
- 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 because `any` disables type checking.
- In the second case, the `res2` variable also has the `any` type. This means that a single usage of `any` 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:

1. Compiler options in tsconfig.
2. TypeScript's standard library.
3. Project dependencies.
4. Explicit use of `any` in a codebase.

I have already written articles on [Key Considerations in tsconfig](https://dev.to/nodge/unleashing-the-power-of-typescript-key-considerations-in-tsconfig-41oo) and [Improving Standard Library Types](https://dev.to/nodge/unleashing-the-power-of-typescript-improving-standard-library-types-47i0) 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](https://eslint.org) 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](https://typescript-eslint.io/) plugin. Most likely, this plugin has already been installed in your project. But if not, you can follow the official [getting started guide](https://typescript-eslint.io/getting-started).

The most common configuration for `typescript-eslint` is as follows:

```tsx
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.

```tsx
// ❌ 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](https://www.typescriptlang.org/docs/handbook/type-inference.html). 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:

```tsx
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:

```tsx
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. 

```tsx
// ❌ 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.

```tsx
// ❌ 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](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates) or a validation library such as [Zod](https://github.com/colinhacks/zod/pulse) or [Superstruct](https://github.com/ianstormtaylor/superstruct). For instance, let's write the `parseFormValues` function that narrows the precise type of parsed data.

```tsx
// ✅ 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:

```tsx
// ✅ 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:

```tsx
// ❌ 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.

```tsx
// ✅ 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`:

```tsx
// ❌ 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 the `userInfo` object is unsafe, as it will be `null` 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](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation) yourself. An example with the fixed library types would look like this:

```tsx
// ✅ 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. 

```tsx
// ❌ 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:

```tsx
// ✅ 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!

## Useful Links

- [Library: typescript-eslint](https://typescript-eslint.io/)
- [Article: Key Considerations in tsconfig](https://dev.to/nodge/unleashing-the-power-of-typescript-key-considerations-in-tsconfig-41oo)
- [Article: Improving Standard Library Types](https://dev.to/nodge/unleashing-the-power-of-typescript-improving-standard-library-types-47i0)
- [Docs: Type Narrowing in TypeScript](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)
- [Docs: Type Inference in TypeScript](https://www.typescriptlang.org/docs/handbook/type-inference.html)
- [Library: Zod](https://zod.dev/)
- [Library: Superstruct](https://docs.superstructjs.org/)