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.
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
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)
);
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.
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
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.
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.
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;
}
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
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.
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(),
})
),
});
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>;
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
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.
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.
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,
};
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
By using declaration merging, we can fully resolve the issue of any
types in TypeScript's standard library.
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;
}
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
}
To fix this, we can define the Body
interface once in our project as follows:
declare global {
interface Body {
json(): Promise<unknown>;
}
}
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;
}
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.
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
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;
}
}
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
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[]
}
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[]
}
Unfortunately, the recently introduced method for cloning objects also returns any
.
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:
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.
const user = {
name: 'John',
age: 30,
};
const copy = structuredClone(user);
// ^? { name: string, age: number }
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>
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>>;
}
}
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>
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";
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.
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.
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!