visit
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 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('//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)
);
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('//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
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
.
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('//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
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(),
})
),
});
type PokemonListResponse = z.infer<typeof schema>;
async function fetchPokemons() {
const response = await fetch('//pokeapi.co/api/v2/pokemon');
const data = (await response.json()) as unknown;
// Validate the response
return schema.parse(data);
}
const pokemons = await fetchPokemons();
// ^? PokemonListResponse
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('//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
declare global {
declare function structuredClone<T>(value: T, options?: StructuredSerializeOptions): T;
}
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 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.