Unleashing the Power of TypeScript: Improving Standard Library Types

nodge

Maksim Zemskov

Posted on August 14, 2023

Unleashing the Power of TypeScript: Improving Standard Library Types

In my previous article, we discussed how to configure TypeScript’s compiler to catch more errors, reduce usage of the any type, and obtain a better Developer Experience. However, properly configuring the tsconfig file is not enough. Even when following all the recommendations, there is still a significant risk of suboptimal type checking quality in our codebase.

The issue is that our code is not the only code required to build an application. The standard library and runtime environment are also involved in type checking. These refer to the JavaScript methods and Web Platform APIs that are available in the global scope, including methods for working with arrays, the window object, Fetch API, and more.

In this article, we will explore some of the most common issues with TypeScript's standard library and ways to write safer, more reliable code.

The Issue with TypeScript's Standard Library

While the TypeScript’s Standard Library provides high-quality type definitions for the most part, some widely-used APIs have type declarations that are either too permissive or too restrictive.

The most common issue with too permissive types is the use of any instead of more precise types, such as unknown. The Fetch API is the most common source of type safety issues in the standard library. The json() method returns a value of type any, which can lead to runtime errors and type mismatches. The same goes for the JSON.parse method.

async function fetchPokemons() {
    const response = await fetch('https://pokeapi.co/api/v2/pokemon');
    const data = await response.json();
    return data;
}

const pokemons = await fetchPokemons();
//    ^?  any

pokemons.data.map(pokemon => pokemon.name);
//            ^  TypeError: Cannot read properties of undefined
Enter fullscreen mode Exit fullscreen mode

On the other hand, there are APIs with unnecessarily restrictive type declarations, which can lead to a poorer developer experience. For example, the Array.filter method works counter-intuitively, requiring developers to manually type cast or write type guards.

// the type of filteredArray is Array<number | undefined>
const filteredArray = [1, 2, undefined].filter(Boolean);

// the type of filteredArray is Array<number>
const filteredArray = [1, 2, undefined].filter(
    (item): item is number => Boolean(item)
);
Enter fullscreen mode Exit fullscreen mode

There is no easy way to upgrade or replace type declarations for the standard library since its type definitions are shipped with the TypeScript compiler. However, there are several ways to work around this issue if we want to get the most out of using TypeScript. Let's explore some options using the Fetch API as an example.

Using Type Assertions

One solution that quickly comes to mind is to manually specify a type. To do this, we need to describe the response format and cast any to the desired type. By doing so, we can isolate the use of any to a small piece of the codebase, which is already much better than using the returned any type throughout a program.

interface PokemonListResponse {
    count: number;
    next: string | null;
    previous: string | null;
    results: Pokemon[];
}

interface Pokemon {
    name: string;
    url: string;
}

async function fetchPokemons() {
    const response = await fetch('https://pokeapi.co/api/v2/pokemon');
    const data = await response.json() as PokemonListResponse;
    //                                 ^  Manually cast the any
    //                                    to a more precise type
    return data;
}

const pokemons = await fetchPokemons();
//    ^?  PokemonListResponse
Enter fullscreen mode Exit fullscreen mode

In addition, TypeScript will now highlight errors with access to non-existent fields. However, it should be understood that type casting imposes additional responsibility on us to accurately describe the type that is returned from the server.

pokemons.data.map(pokemon => pokemon.name);
//       ^  Error: Property 'data' does not exist on type 'PokemonListResponse'
//          We shold use the 'results' field here.
Enter fullscreen mode Exit fullscreen mode

Type assertions can be risky and should be used with caution. They can result in unexpected behavior if the assertion is incorrect. For example, there is a high risk of making mistakes when describing types, such as overlooking the possibility of a field being null or undefined.

Additionally, if the response format on a server changes unexpectedly, we may not become aware of it as quickly as possible.

Using Type Guards

We can enhance the solution by first casting any to unknown. This clearly indicates that the fetch function can return any type of data. We then need to verify that the response has the data we need by writing a type guard, as shown below:

function isPokemonListResponse(data: unknown): data is PokemonListResponse {
    if (typeof data !== 'object' || data === null) return false;
    if (typeof data.count !== 'number') return false;
    if (data.next !== null && typeof data.next !== 'string') return false;
    if (data.previous !== null && typeof data.previous !== 'string') return false;

    if (!Array.isArray(data.results)) return false;
    for (const pokemon of data.results) {
        if (typeof pokemon.name !== 'string') return false;
        if (typeof pokemon.url !== 'string') return false;
    }

    return true;
}
Enter fullscreen mode Exit fullscreen mode

The type guard function takes a variable with the unknown type as input. The is operator is used to specify the output type, indicating that we have checked the data in the data variable and it has this type. Inside the function, we write all the necessary checks that verify all the fields we are interested in.

We can use the resulting type guard to narrow the unknown type down to the type we want to work with. This way, if the response data format changes, we can quickly detect it and handle the situation in application logic.

async function fetchPokemons() {
    const response = await fetch('https://pokeapi.co/api/v2/pokemon');
    const data = (await response.json()) as unknown;
    //                                   ^  1. Cast to unknown

    // 2. Validate the response
    if (!isPokemonListResponse(data)) {
        throw new Error('Неизвестный формат ответа');
    }

    return data;
}

const pokemons = await fetchPokemons();
//    ^?  PokemonListResponse
Enter fullscreen mode Exit fullscreen mode

However, writing type guards can be tedious, especially when dealing with large amounts of data. Additionally, there is a high risk of making mistakes in the type guard, which is equivalent to making a mistake in the type definition itself.

Using the Zod Library

To simplify the writing of type guards, we can use a library for data validation such as Zod. With Zod, we can define a data schema and then call a function that checks the data format against this schema.

import { z } from 'zod';

const schema = z.object({
    count: z.number(),
    next: z.string().nullable(),
    previous: z.string().nullable(),
    results: z.array(
        z.object({
            name: z.string(),
            url: z.string(),
        })
    ),
});
Enter fullscreen mode Exit fullscreen mode

These types of libraries are initially developed with TypeScript in mind, so they have a nice feature. They allow us to describe the data schema once and then automatically get the type definition. This eliminates the need to manually describe TypeScript interfaces and removes duplication.

type PokemonListResponse = z.infer<typeof schema>;
Enter fullscreen mode Exit fullscreen mode

This function essentially acts as a type guard, which we don't have to write manually.

async function fetchPokemons() {
    const response = await fetch('https://pokeapi.co/api/v2/pokemon');
    const data = (await response.json()) as unknown;
    // Validate the response
    return schema.parse(data);
}

const pokemons = await fetchPokemons();
//    ^?  PokemonListResponse
Enter fullscreen mode Exit fullscreen mode

As a result, we get a reliable solution that leaves no room for human error. Mistakes in type definitions cannot be made since we don't write them manually. Mistakes in type guards are also impossible. Mistakes in the schema can be made, but we will quickly become aware of them during development.

Alternatives for Zod

Zod has many alternatives that differ in functionality, bundle size, and performance. For each application, you can choose the most suitable option.

For example, the superstruct library is a lighter alternative to Zod. This library is more suitable for use on the client side since it has a relatively small size (13.1 kB vs 3.4 kB).

The typia library is a slightly different approach with ahead-of-time compilation. Due to compilation stage, data validation works significantly faster. This can be especially important for heavy server code or for large volumes of data.

Fixing the Root Cause

Using libraries such as Zod for data validation can help overcome the issue of any types in TypeScript's standard library. However, it is still important to be aware of standard library methods that return any, and to replace these types with unknown whenever we use these methods.

Ideally, the standard library should use unknown types instead of any. This would enable the compiler to suggest all the places where a type guard is needed. Fortunately, TypeScript's declaration merging feature provides this possibility.

In TypeScript, interfaces have a useful feature where multiple declarations of an interface with the same name will be merged into one declaration. For example, if we have an interface User with a name field, and then declare another interface User with an age field, the resulting User interface will have both the name and age fields.

interface User {
    name: string;
}

interface User {
    age: number;
}

const user: User = {
    name: 'John',
    age: 30,
};
Enter fullscreen mode Exit fullscreen mode

This feature works not only within a single file but also globally across the project. This means that we can use this feature to extend the Window type or even to extend types for external libraries, including the standard library.

declare global {
    interface Window {
        sayHello: () => void;
    }
}

window.sayHello();
//     ^  TypeScript now knows about this method
Enter fullscreen mode Exit fullscreen mode

By using declaration merging, we can fully resolve the issue of any types in TypeScript's standard library.

Better Types for Fetch API

To improve the Fetch API from the standard library, we need to correct the types for the json() method so that it always returns unknown instead of any. Firstly, we can use the "Go to Type Definition" function in an IDE to determine that the json method is part of the Response interface.

interface Response extends Body {
    readonly headers: Headers;
    readonly ok: boolean;
    readonly redirected: boolean;
    readonly status: number;
    readonly statusText: string;
    readonly type: ResponseType;
    readonly url: string;
    clone(): Response;
}
Enter fullscreen mode Exit fullscreen mode

However, we cannot find the json() method among the methods of Response. Instead, we can see that the Response interface inherits from the Body interface. So, we look into the Body interface to find the method we need. As we can see, the json() method actually returns the any type.

interface Body {
    readonly body: ReadableStream<Uint8Array> | null;
    readonly bodyUsed: boolean;
    arrayBuffer(): Promise<ArrayBuffer>;
    blob(): Promise<Blob>;
    formData(): Promise<FormData>;
    text(): Promise<string>;
    json(): Promise<any>;
    //              ^  We are going to fix this
}
Enter fullscreen mode Exit fullscreen mode

To fix this, we can define the Body interface once in our project as follows:.

declare global {
    interface Body {
        json(): Promise<unknown>;
    }
}
Enter fullscreen mode Exit fullscreen mode

Thanks to declaration merging, the json() method will now always return the unknown type.

async function fetchPokemons() {
    const response = await fetch('https://pokeapi.co/api/v2/pokemon');
    const data = await response.json();
    //    ^?  unknown
    return data;
}
Enter fullscreen mode Exit fullscreen mode

This means that forgetting to write a type guard will no longer be possible, and the any type will no longer be able to sneak into our code.

Better Types for JSON.parse

In the same way, we can fix JSON parsing. By default, the parse() method returns the any type, which can lead to runtime errors when using parsed data.

const data = JSON.parse(text);
//    ^?  any
Enter fullscreen mode Exit fullscreen mode

To fix this, we need to figure out that the parse() method is part of the JSON interface. Then we can declare the type in our project as follows:

declare global {
    interface JSON {
        parse(
            text: string, 
            reviver?: (this: any, key: string, value: any) => any
        ): unknown;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, JSON parsing always returns the unknown type, for which we will definitely not forget to write a type guard. This leads to a safer and more maintainable codebase.

const data = JSON.parse(text);
//    ^?  unknown
Enter fullscreen mode Exit fullscreen mode

Better Types for Array.isArray

Another common example is checking if a variable is an array. By default, this method returns an array of any, which is essentially the same as just using any.

if (Array.isArray(userInput)) {
    console.log(userInput);
    //          ^?  any[]
}
Enter fullscreen mode Exit fullscreen mode

We have already learned how to fix the issue. By extending the types for the array constructor as shown below, the method now returns an array of unknown, which is much safer and more accurate.

declare global {
    interface ArrayConstructor {
        isArray(arg: any): arg is unknown[];
    }
}

if (Array.isArray(userInput)) {
    console.log(userInput);
    //          ^?  unknown[]
}
Enter fullscreen mode Exit fullscreen mode

Better Types for structuredClone

Unfortunately, the recently introduced method for cloning objects also returns any.

const user = {
    name: 'John',
    age: 30,
};

const copy = structuredClone(user);
//    ^?  any
Enter fullscreen mode Exit fullscreen mode

Fixing it is just as simple as the previous methods. However, in this case, we need to add a new function signature instead of augmenting the interface. Fortunately, declaration merging works for functions just like it does for interfaces. Therefore, we can fix the issue as follows:

declare global {
    declare function structuredClone<T>(value: T, options?: StructuredSerializeOptions): T;
}
Enter fullscreen mode Exit fullscreen mode

The cloned object will now be of the same type as the original object.

const user = {
    name: 'John',
    age: 30,
};

const copy = structuredClone(user);
//    ^?  { name: string, age: number }
Enter fullscreen mode Exit fullscreen mode

Better Types for Array.filter

Declaration merging is not only useful for fixing the any type issue, but it can also improve the ergonomics of the standard library. Let's consider the example of the Array.filter method.

const filteredArray = [1, 2, undefined].filter(Boolean);
//    ^?  Array<number | undefined>
Enter fullscreen mode Exit fullscreen mode

We can teach TypeScript to automatically narrow the array type after applying the Boolean filter function. To do so, we need to extend the Array interface as follows:

type NonFalsy<T> = T extends false | 0 | "" | null | undefined | 0n ? never : T;

declare global {
    interface Array<T> {
      filter(predicate: BooleanConstructor, thisArg?: any): Array<NonFalsy<T>>;
    }
}
Enter fullscreen mode Exit fullscreen mode

Describing how the NonFalsy type works requires a separate article, so I will leave this explanation for another time. The important thing is that now we can use the shorthand form of the filter and get the correct data type as a result.

const filteredArray = [1, 2, undefined].filter(Boolean);
//    ^?  Array<number>
Enter fullscreen mode Exit fullscreen mode

Introducing ts-reset

TypeScript's standard library contains over 1,000 instances of the any type. There are many opportunities to improve the developer experience when working with strictly typed code. One solution to avoid having to fix the standard library yourself is to use the ts-reset library. It is easy to use and only needs to be imported once in your project.

import "@total-typescript/ts-reset";
Enter fullscreen mode Exit fullscreen mode

The library is relatively new, so it does not yet have as many fixes to the standard library as I would like. However, I believe this is just the beginning. It is important to note that ts-reset only contains safe changes to global types that do not lead to potential runtime bugs.

Caution Regarding Usage in Libraries

Improving TypeScript's standard library has many benefits. However, it is important to note that redefining global types of the standard library limits this approach to applications only. It is mostly unsuitable for libraries because using such a library would unexpectedly change the behavior of global types for the application.

In general, it is recommended to avoid modifying TypeScript's standard library types in libraries. Instead, you can use static analysis tools to achieve similar results in terms of code quality and type safety, which are suitable for library development. I will write another article about this soon.

Conclusion

TypeScript's standard library is a crucial component of the TypeScript Compiler, providing a comprehensive range of built-in types for working with JavaScript and Web Platform APIs. However, the standard library is not perfect, and there are issues with some of the type declarations that can lead to suboptimal type checking quality in our codebase. In this article, we explored some of the most common issues with TypeScript's standard library and ways to write safer and more reliable code.

By using type assertions, type guards, and libraries such as Zod, we can improve the type safety and code quality in our codebase. Additionally, we can fix the root cause of the issue by using declaration merging to improve the type safety and ergonomics of TypeScript's standard library.

I hope you have learned something new from this article. In the next article, we will discuss how to use static analysis tools to further improve type safety. Thank you for reading!

Useful Links

💖 💪 🙅 🚩
nodge
Maksim Zemskov

Posted on August 14, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related