In my , we discussed how to configure TypeScript’s compiler to catch more errors, reduce usage of the 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. previous article any 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 instead of more precise types, such as . The Fetch API is the most common source of type safety issues in the standard library. The method returns a value of type , which can lead to runtime errors and type mismatches. The same goes for the method. any unknown json() any JSON.parse 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 method works counter-intuitively, requiring developers to manually type cast or write type guards. Array.filter // 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 to the desired type. By doing so, we can isolate the use of to a small piece of the codebase, which is already much better than using the returned type throughout a program. any any any 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 or . null 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 to . This clearly indicates that the 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: any unknown fetch 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 type as input. The operator is used to specify the output type, indicating that we have checked the data in the variable and it has this type. Inside the function, we write all the necessary checks that verify all the fields we are interested in. unknown is data We can use the resulting type guard to narrow the 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. unknown 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 . With Zod, we can define a data schema and then call a function that checks the data format against this schema. Zod 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. 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 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). superstruct The 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. typia Fixing the Root Cause Using libraries such as Zod for data validation can help overcome the issue of types in TypeScript's standard library. However, it is still important to be aware of standard library methods that return , and to replace these types with whenever we use these methods. any any unknown Ideally, the standard library should use types instead of . 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. unknown any 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 with a name field, and then declare another interface with an age field, the resulting interface will have both the name and age fields. User User User 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 type or even to extend types for external libraries, including the standard library. Window 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 types in TypeScript's standard library. any Better Types for Fetch API To improve the Fetch API from the standard library, we need to correct the types for the method so that it always returns instead of . Firstly, we can use the "Go to Type Definition" function in an IDE to determine that the method is part of the interface. json() unknown any json Response 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 method among the methods of . Instead, we can see that the interface inherits from the interface. So, we look into the interface to find the method we need. As we can see, the method actually returns the type. json() Response Response Body Body json() any 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 interface once in our project as follows: Body declare global { interface Body { json(): Promise<unknown>; } } Thanks to declaration merging, the method will now always return the type. json() unknown 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 type will no longer be able to sneak into our code. any Better Types for JSON.parse In the same way, we can fix JSON parsing. By default, the method returns the type, which can lead to runtime errors when using parsed data. parse() any const data = JSON.parse(text); // ^? any To fix this, we need to figure out that the method is part of the interface. Then we can declare the type in our project as follows: parse() JSON declare global { interface JSON { parse( text: string, reviver?: (this: any, key: string, value: any) => any ): unknown; } } Now, JSON parsing always returns the type, for which we will definitely not forget to write a type guard. This leads to a safer and more maintainable codebase. unknown 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 , which is essentially the same as just using . any 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 , which is much safer and more accurate. unknown 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 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 } Better Types for Array.filter Declaration merging is not only useful for fixing the type issue, but it can also improve the ergonomics of the standard library. Let's consider the example of the method. any Array.filter 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 interface as follows: Array 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 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. NonFalsy const filteredArray = [1, 2, undefined].filter(Boolean); // ^? Array<number> Introducing ts-reset TypeScript's standard library contains over 1,000 instances of the 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. any ts-reset 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 only contains safe changes to global types that do not lead to potential runtime bugs. ts-reset 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 Type Narrowing Any type ts-reset Zod Superstruct Typia