Unleashing the Power of TypeScript: Improving Standard Library Types
Tags: typescript, frontend, node, tutorial
Published: 2023-08-14 12:27:00

In [my previous article](https://dev.to/nodge/unleashing-the-power-of-typescript-key-considerations-in-tsconfig-41oo), 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.

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

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.

```tsx
// 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)
);
```

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.

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

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.

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

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:

```tsx
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;
}
```

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.

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

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](https://zod.dev/). With Zod, we can define a data schema and then call a function that checks the data format against this schema.

```tsx
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(),
        })
    ),
});
```

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. 

```tsx
type PokemonListResponse = z.infer<typeof schema>;
```

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

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

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](https://docs.superstructjs.org/) 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](https://typia.io/docs/) 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.

```tsx
interface User {
    name: string;
}

interface User {
    age: number;
}

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

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.

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

window.sayHello();
//     ^  TypeScript now knows about this method
```

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.

```tsx
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;
}
```

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.

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

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

```tsx
declare global {
    interface Body {
        json(): Promise<unknown>;
    }
}
```

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

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

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.

```tsx
const data = JSON.parse(text);
//    ^?  any
```

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:

```tsx
declare global {
    interface JSON {
        parse(
            text: string, 
            reviver?: (this: any, key: string, value: any) => any
        ): unknown;
    }
}
```

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.

```tsx
const data = JSON.parse(text);
//    ^?  unknown
```

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

```tsx
if (Array.isArray(userInput)) {
    console.log(userInput);
    //          ^?  any[]
}
```

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.

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

if (Array.isArray(userInput)) {
    console.log(userInput);
    //          ^?  unknown[]
}
```

## Better Types for structuredClone

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

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

const copy = structuredClone(user);
//    ^?  any
```

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:

```tsx
declare global {
    declare function structuredClone<T>(value: T, options?: StructuredSerializeOptions): T;
}
```

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

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

const copy = structuredClone(user);
//    ^?  { name: string, age: number }
```

## 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.

```tsx
const filteredArray = [1, 2, undefined].filter(Boolean);
//    ^?  Array<number | undefined>
```

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:

```tsx
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>>;
    }
}
```

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.

```tsx
const filteredArray = [1, 2, undefined].filter(Boolean);
//    ^?  Array<number>
```

## 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](https://github.com/total-typescript/ts-reset) library. It is easy to use and only needs to be imported once in your project.

```tsx
import "@total-typescript/ts-reset";
```

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

- [Declaration Merging](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#disallowed-merges)
- [Type Narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)
- [Any type](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#any)
- [ts-reset](https://github.com/total-typescript/ts-reset)
- [Zod](https://zod.dev/)
- [Superstruct](https://docs.superstructjs.org/)
- [Typia](https://typia.io/docs/)