Making TypeScript Truly "Strongly Typed"

Published on
11-09-2023
Author
Product Minting
Category
Interviews
https://cdn.aisys.pro/stories/1694440847904-31870.jpg

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 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 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 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 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!



The Web Development Writing Contest is brought to you by IONOS in partnership with HackerNoon. Publish your stories on #web-development to win from $6000!!!

Experience coding the way it's meant to be with IONOS Cloud Cubes - powerful KVM-based virtual servers designed for maximum flexibility and on-demand scalability. Start for free: IONOS virtual servers for devs.





Discussion (20)

Not yet any reply