With each new version, TypeScript continues to evolve and improve, bringing new features and enhancements to the table. The latest release, TypeScript 5, promises to be smaller, simpler, and faster than its predecessors.
From performance enhancements to streamlined syntax, TypeScript 5 is poised to be a game-changer for developers looking to build high-quality web applications with ease. In this article, we will take a closer look at TypeScript 5 and explore:
Let’s dive in!
TypeScript 5 brings a range of new features and enhancements to the table. These include the following:
const
type parameters--moduleResolution bundler
extends
--verbatimModuleSyntax
export type *
--build
@overload
support in JSDoc@satisfies
support in JSDocIn the rest of this section, we’ll explain each feature and enhancement in the list in more detail.
TypeScript 5 introduces significant improvements in its infrastructure compared to previous versions. The migration from namespaces to modules enables a modular infrastructure, supports modern build tooling, and allows for various optimizations.
In addition to the infrastructure changes, TypeScript 5 offers the following enhancements over TypeScript 4.9:
Furthermore, TypeScript enhances uniformity in object types and reduces the data stored within the compiler, resulting in reduced polymorphic operations and balanced memory usage despite the uniform object shapes.
Decorators in TypeScript 5 work differently from decorators in the previous versions.
The previous versions of TypeScript decorators only allowed developers to alter limited properties of the function they’re modifying. In TypeScript 5, decorators allow developers to replace the function they’re modifying completely — in other words, to completely customize the behavior of a method in a class.
TypeScript 5 decorators work by replacing the method you are applying them to while keeping a reference to that method to call whenever it has done its job.
The following is an example of a decorator:
function monitoredMethod(originalMethod: any, _context: any) { function replacementMethod(this: any, ...args: any[]) { console.log('Initializing method call...'); console.log('Calling method...'); let result = originalMethod.call(this, ...args); console.log('Method called successfully!'); return result; } return replacementMethod; }
If you apply the monitoredMethod
decorator to a method, the TypeScript compiler replaces that method with replacementMethod
. The compiler references the replaced method for replacementMethod
to call whenever it needs to.
Consider the example below:
class Robot { name: string; constructor (name: string) { this.name = name; } sayHello(name:string) { console.log(`${this.name} says hello to ${name}`); } }
We can apply the monitoredMethod
decorator to the Robot.sayHello
method, as done below:
class Robot { name: string; constructor (name: string) { this.name = name; } @monitoredMethod sayHello(name:string) { console.log(`${this.name} says hello to ${name}`); } }
When you call the Robot.sayHello
method, you’ll get the output below:
Initializing method call... Calling method... myRobot says hello to everyone Method called successfully!
const
type parametersThe const
type parameter feature allows developers to create const
-like inferences for type parameter declarations. In other words, when the compiler is inferring the type of an object from a function parameter, it chooses a more specific type.
Let’s look at an example below:
function printName<T extends readonly string[]>(args: T, index: number) :void { console.log(args[index]); } printName(["John", "Doe", "Smith"], 1);
The type inference for the printName
function call is:
function printName<string[]>(args: string[], index: number): void
Now, let’s see what happens when we add the const
type parameter to the printName
declaration:
> function printName<const T extends readonly string[]>(args: T, index: number) :void { console.log(args[index]); } printName(["John", "Doe", "Smith"], 1);
The type inference for the printName
function call becomes more specific:
function printName<readonly ["John", "Doe", "Smith"]>(args: readonly ["John", "Doe", "Smith"], index: number): void
By using const
type parameters, developers can avoid the easy-to-forget process of adding as const
in certain places to achieve the desired inference.
However, you should note that the const
modifier only affects inferences that you make within the call. const
identifiers written outside the call won’t see any change in behavior in the function call:
function printName<const T extends readonly string[]>(args: T, index: number) :void { console.log(args[index]); } const arr = ["John", "Doe", "Smith"]; printName(arr, 1); // `arr` becomes type `string[]` when you pass it to the function
Additionally, it’s crucial to use readonly string[]
instead of string[]
as the constraint for functions that require immutable arrays. Without the readonly
modifier, the compiler won’t give the immutable arrays a more specific type inference. For example:
// For example, this: function printName<readonly ["John", "Doe", "Smith"]>(args: readonly ["John", "Doe", "Smith"], index: number): void // Becomes this: function printName<string[]>(args: string[], index: number): void
--moduleResolution bundler
The --moduleResolution bundler
is a module resolution strategy introduced in TypeScript 4.7. It works for bundlers and runtimes that understand TypeScript natively.
--moduleResolution bundler
models how bundlers work. It also implements a hybrid lookup strategy. The strategy is a superset of both the ECMAScript module support introduced in TypeScript 4.7 and CommonJS lookup rules in Node.js.
The --moduleResolution bundler
is a good fit for developers using modern bundlers. These bundlers include Vite, Esbuild, SWC, Webpack, and Parcel. It also works for other bundlers that implement a hybrid lookup strategy.
This new module resolution strategy is flexible and customizable. It allows users of different bundlers and runtimes to customize TypeScript’s resolution settings. It supports the resolution of package.json
exports and imports. Furthermore, you can enable, disable, or customize configuration options.
Note that the --moduleResolution bundler
does not support the resolution of require
calls. This means that TypeScript forbids the import mod = require ("foo")
syntax in its files. However, require
calls are not errors in JavaScript files; they only ever return the type any
.
extends
TypeScript’s ability to support multiple configurations in extends
is a powerful feature. It allows developers to define a hierarchy of configuration files. They can then choose to extend and override the hierarchy.
This feature comes in handy when managing many projects, especially when working on a large project that requires many configuration files.
Let’s have a look at the tsconfig.json
file below:
{ "extends": "../../../tsconfig.base.json", "compilerOptions": { "outDir": "../lib", // ... } }
extends
allows you to copy over fields from the tsconfig.base.json
file to your tsconfig.json
file.
There are scenarios where developers might want to extend many configuration files, such as when using a TypeScript base configuration file shipped to npm. To achieve this, TypeScript 5.0 allows the extends
field to take many entries. For example:
{ "extends": ["config1.json", "config2.json", "config3.json"], "compilerOptions": { // ... } }
This allows developers to define a hierarchy of configuration files. You can extend and override any field in the hierarchy in the final configuration file.
Adding many entries in extends
is like extending the last configuration file. In this case, the last file extends
the second to the last, and so on. If any fields conflict, the latter entry wins.
Have a look at this example:
// tsconfig1.json { "compilerOptions": { "strictNullChecks": true } } // tsconfig2.json { "compilerOptions": { "outDir": "./lib" } } // tsconfig.json { "extends": ["./tsconfig1.json", "./tsconfig2.json"], "files": ["./index.ts"] }
In this example, the tsconfig.json file inherits both strictNullChecks
and outDir
. Now, let’s look at another version of the example:
// tsconfig1.json { "compilerOptions": { "strictNullChecks": true, "outDir": "./build" } } // tsconfig2.json { "compilerOptions": { "outDir": "./lib" } } // tsconfig.json { "extends": ["./tsconfig1.json", "./tsconfig2.json"], "files": ["./index.ts"] }
In this example, tsconfig.json
inherits both configuration files, but outDir
will be set to "./lib"
because of the hierarchy in extends
.
This allows for greater flexibility when defining configuration files for complex projects, especially in projects that need several configuration files. In this case, developers can define a hierarchy of files, and each file defines only the options it requires.
Supporting multiple configurations eliminates the need to define redundant options across many files. This, in turn, makes the configuration process more streamlined and less prone to errors.
Furthermore, using multiple configurations in extends
allows developers to create reusable configuration files. You can share these files across many projects. This allows for more consistency in codebases. It also ensures that developers follow best practices across projects.
Finally, supporting many configurations enhances the flexibility and maintainability of TypeScript projects. It encourages developers to define a hierarchy of configurations, which in turn allows greater flexibility in defining the configuration files for complex projects.
Up until version 5, TypeScript categorized enums into two groups: numeric enums and literal — or union — enums.
Numeric enums have several important features, one of which is their ability to automatically assign numeric values to each member. You can also assign them to numeric constants or computed values.
Literal enums give each enum member its own unique type and value. The data type of the enum itself is a “union” of all its member types:
enum Color { Red, Blue, Green, Yellow } type Colors = Color type Colors2 = Color.Red | Color.Blue | Color.Green | Color.Yellow // In this case Color, Colors, and Colors2 are the same type
This is good, but it means that TypeScript determines the value and type of the member at compile time. That means you can only assign string and numeric literals to a member, not computed values. This is because the data type of computed values is usually determined at runtime.
TypeScript 5 introduces a hybrid enum type that has the best of both numeric enums and literal enums. This enum is still called a union type, but it works in a different way from the union type before TypeScript 5:
function getColor(color: string) { return 4 } enum Colors { Red, Green, Yellow = "yellow", Orange = getColor("orange") // computed values must be a 'number' datatype } // As you can see, numeric and literal enum style works in the Color enum
The major difference between this hybrid and the union enums in previous versions is its flexibility. The example above shows that this enum allows you to compute values in the enum field. In comparison, the union enums from previous TypeScript versions throw errors when you try this.
Previous TypeScript compilers show the error below when you run the code block:
index.ts:8:14 - error TS2553: Computed values are not permitted in an enum with string valued members. 8 Orange = getColor("orange") // computed values must be a 'number' datatype ~~~~~~~~~~~~~~~~~~
Resolution customization flags allow developers to change TypeScript’s package resolution in their projects.
Resolving package imports has always been an issue in TypeScript. TypeScript compiles to JavaScript, which uses a different mechanism for importation and exportation. The compiler needs to resolve the package names to fit JavaScript’s mechanism.
TypeScript 5 introduces five new flags for changing the compiler resolution process:
--allowImportingTsExtensions
--resolvePackageJsonExports
--resolvePackageJsonImports
--allowArbitraryExtensions
--customConditions
Let’s look at them each in more detail.
--allowImportingTsExtensions
Applying this flag to tsc
allows TypeScript code to import other TypeScript code. This means you can now add a TypeScript extension — i.e., ts
, tsx
, and mts
— to the import name:
// originally you can't add the `.ts` extension to your imports
import app from "./app.ts"; // It'll throw an error
// But with the --allowImportingTsExtension
, you can
This flag only works when --noEmit
and --emitDeclarationOnly
are enabled.
--resolvePackageJsonExports
This flag prompts tsc
to consult the exports
field in your package.json
file:
// package.json { "exports": { ".": "./index.js" } }
exports
declares entry points for your project. Node checks this field while importing a script in a node_modules
package.
exports
can be used as an alternative to the main
field. In fact, it is a better alternative because it provides more features, like conditional exports and subpath exports.
Note that --resolvePackageJsonExports
defaults to true
under the node16
, nodenext
, and bundler
options for --moduleResolution
.
--resolvePackageJsonImports
This flag enables the imports
field to work for your TypeScript code. The imports
field is also an element of the package.json
file. It allows developers to create mappings in subpath imports:
import app from './app.js'; // Let's look at this example /** * Imagine we fixed the `import` field below in our package.json: * +--------------------------------+ * | ... | * | "import": { | * | "#app": { | * | "node": "app-node.js", | * | "default": "app.js" | * | } | * | }, | * | ... | * +--------------------------------+ */ // With the `import` field set in our package.json, we can now do this: import app from "#app"; // The `import` will import the required file based on your environment // In node, the `import` will import `app-node.js` // If no condition is set, it'll import `app.js` because it's the default
Like --resolvePackageJsonExports
, --resolvePackageJsonImports
also defaults to true under the node16
, nodenext
, and bundler
options for --moduleResolution
.
--allowArbitraryExtensions
Usually, TypeScript allows developers to import JavaScript files into their code with type definitions. Type definitions are files that end with the .d.ts
extension.
Type definitions used to be limited to just JavaScript files. But with TypeScript 5, adding the --allowArbitraryExtensions
flag to tsc
will allow you to extend type definitions to other types of files.
For example, you can create type definitions for a CSS file and store them in a file with the format {file name}.{extension}.d.ts
, or the more recommended {file name}.d.{extension}.ts
.
Here’s an example CSS file:
/* app.css */ .send-button { background-color: green; }
Here’s the associated app.d.css.ts
file:
// app.d.css.ts declare const styles: { sendButton: string; }; export default styles;
Here’s a resulting TypeScript file that imports the CSS file:
// App.tsx import styles from "./app.css"; styles.sendButton;
Whenever the file name is arbitrary, the TypeScript compiler will look for a type definition for that file.
--customConditions
This flag allows you to define custom conditions that you can apply to conditional exports or imports.
Let’s say you have three files like those indicated below:
// func-to-call.ts import functionToCall from "#func-to-call"; functionToCall(); // func-to-call-default.ts export default function func() { console.log("This is normal"); } // func-to-call-my-condition.ts export default function func () { console.log("This is from the custom condition"); }
We can add a custom condition that makes sure that #func-to-call
in the import references the func-to-call-my-condition.ts
file. To add the custom condition, add it to the customCondition
field in the tsconfig.json
file:
{ "compilerOptions": { "moduleResolution": "nodenext", "customConditions": ["my-condition"] } }
Likewise, we can fit the condition into either the imports
or exports
in the package.json
file:
// imports { "imports": { "#func-to-call": { "my-condition": "./func-to-call-my-condition.js", "default": "./func-to-call.js" } } } // exports { "exports": { ".": { "my-condition": "./func-to-call-my-condition.js", "default": "./func-to-call.js" } } }
--verbatimModuleSyntax
Before TypeScript 5 introduced this flag, import elision issues were difficult to handle. Import elision is one of the strategies that TypeScript uses to handle imports.
Let’s take a look at the example below:
// car.ts type Car = { name: string, age: number }; export default Car; // index.ts import Car from "./car"; function car(c: Car) { // ... }
The ES JavaScript output of this will be:
// car.js export {}; // index.js function car(c) { // ... } export {};
You’ll notice that the import
statements are gone. This is the result of import elision. Removing imports that the compiler thinks aren’t necessary improves the runtime of the JavaScript output.
There are questions that can be raised about this behavior. For example, what if the module has an intended side effect and only exports a type? TypeScript wouldn’t be able to tell if the module has an intended side effect and would remove the import
statement entirely.
Or, what if the developer imports a class to use as a data type? TypeScript would apply import elision to the import
statement.
Another question might be, what if only one of the imports in an import
statement is a type, and others are different? Import elision would only remove the imports that are types.
--verbatimModuleSyntax
solves issues like the above that can arise with import
elisions. This flag gives you an easy way to control elisions that may happen in your code.
To get the desired behavior with the --verbatimModuleSyntax
flag, simply use the type modifier. The type modifier lets you mark which import is a type. This helps the flag handle imports the way you want it to. Let’s have a look at this example:
// index.ts import {Fruit} from "./fruits"; function eatFruit(f: Fruit) { console.log(`eating ${f.name}...`); } // fruits.ts class Fruit { name: string; constructor(name: string) { this.name = name; } } // intended side effect console.log('Initializing fruits...'); export { Fruit };
We’re only using the Fruit
class as a data type, but we want the runtime to execute the module’s side effect.
If we compile this code, the compiler removes the import statement. Without the import statement, the runtime doesn’t run the module. If the runtime doesn’t run the module, it won’t display any side effects.
We want the compiler to handle the module imports, but we also want the runtime to run the module. We can achieve both by applying the --verbatimModuleSyntax
to the compiler and applying the type modifier to the Fruit
import in the import statement:
import {type Fruit} from "./module-elision-type.js"; function eatFruit(f: Fruit) { console.log(`eating ${f.name}...`); }
The JavaScript output of this code will be:
import {} from "./module-elision-type.js"; function eatFruit(f) { console.log(`eating ${f.name}...`); }
The runtime executes the module because tsc
doesn’t erase the import statement. Since the runtime can run the module, it now displays the intended side effect during runtime.
There are other ways to use the type
modifier:
import type { A } from "a"; // Erased entirely. import { b, type c, type d } from "bcd"; // Rewritten to 'import { b } from "bcd";' import { type xyz } from "xyz"; // Rewritten to 'import {} from "xyz";'
export type *
In TypeScript version 3.8, type-only imports were introduced, but their syntax was not initially supported in exports.
However, with the release of TypeScript 5, you now have the ability to include export * from "module"
or export * as namespace from "module"
in your code to export these types. This allows for more flexible and comprehensive module exports in TypeScript.
Take a look at this example:
// food/module.ts export class Fruits { // ... } // food/index.ts export type * as food from "./module"; // main.ts import { food } from "./food"; function checkFruit(f: food.Fruits) { // ... }
--build
TypeScript now allows you to pass these flags in tsc --build
:
--declaration
--emitDeclarationOnly
--declarationMap
--sourceMap
--inlineSourceMap
They allow you to configure what tsc
generates alongside your JavaScript build. These generated files may include source maps, declaration maps, and declaration files.
You may not need these files in development, but you will need them in production. They allow your project to be easily integrated with other projects.
@overload
support in JSDocTypeScript provides the ability to define function overloads. This allows you to specify different sets of arguments for a function.
Consider the following scenario:
function printValue(value: string | number, maximumFractionDigits?: number) { if (typeof value === "number") { const formatter = Intl.NumberFormat("en-US", { maximumFractionDigits, }); value = formatter.format(value); } console.log(value); }
Let’s say we want this function to behave like this:
function printValue(str: string): void; function printValue(num: number, maxFractionDigits?: number): void;
Although it is not possible to achieve the exact behavior, we can use the @overload
annotation in the JSDoc of the function. By doing so, the compiler and IDE highlighting can understand the intended usage of the function:
// @ts-check /** * @overload * @param {string} value * @return {void} */ /** * @overload * @param {number} value * @param {number} [maximumFractionDigits] * @return {void} */ /** * @param {string | number} value * @param {number} [maximumFractionDigits] */ function printValue(value, maximumFractionDigits): void { if (typeof value === "number") { const formatter = Intl.NumberFormat("en-US", { maximumFractionDigits, }); value = formatter.format(value); } console.log(value); }
@satisfies
support in JSDocThis annotation allows you to use a feature like the satisfies
operator for JSDoc. satisfies
is a keyword that you use on objects to verify if they’re compatible with an interface:
interface Config { files?: string | string[]; compilerOptions: { outDir?: string; }; } let config = { files: ["main.ts", "index.ts"], compilerOptions: { outDir: "./build" } } satisfies Config;
Now have a look at the @satisfies
implementation:
// @ts-check /** * @typedef CompilerOptions * @prop {string} [outDir] */ /** * @typedef Config * @prop {string | string[]} [files] * @prop {CompilerOptions} [compilerOptions] */ /** @satisfies {Config} */ let config2 = { files: ["main.ts", "index.ts"], compilerOptions: { outDir: "./build" } };
TypeScript 4.9 introduced the satisfies
keyword, and now TypeScript 5 has introduced the @satisfies
annotation. Both options are functional in your TypeScript code, but only the @satisfies
annotation remains visible even in your JavaScript code.
TypeScript 5.0 introduced several breaking changes and deprecations. Let’s explore each of them in more detail.
TypeScript 5.0 requires a minimum Node.js version of 12.20 and targets ECMAScript 2018.
In TypeScript 5.0, applying a relational operator between a string and a number will result in an error:
function func(ns: number | string) { return ns > 4; }
In the above example, TypeScript prevents implicit string-to-number coercion for the variable ns
. This change necessitates explicit type coercion when using the operator. To explicitly coerce ns
, prefix it with the +
symbol:
function func(ns: number | string) { return +ns > 4; }
Starting from TypeScript 5.0, several flags will no longer be supported. The following flags are affected:
--target es3
--out
--noImplicitUseStrict
--keyofStringsOnly
--suppressExcessPropertyErrors
--suppressImplicitAnyIndexErrors
--noStrictGenericChecks
--charset
--importsNotUsedAsValues
--preserveValueImports
Although these flags can still be used in TypeScript 5.0, they will generate a warning from the compiler. It’s crucial to note that in TypeScript 5.5 and beyond, these flags will be completely unsupported, and attempting to use them will result in an error.
As of this writing, the only flag that appears to have a substitute is --out
, which can be replaced with --outFile
.
TypeScript 5 introduces significant advancements in web development, providing a host of new features and enhancements that make it more compact, straightforward, and faster than ever before.
With improved performance and streamlined syntax, TypeScript 5 is poised to revolutionize the way developers create high-quality web applications effortlessly.
As a superset of JavaScript, TypeScript continues to evolve and enhance, empowering developers with a robust tool for constructing intricate web applications that are dependable, maintainable, and efficient.
With the advent of TypeScript 5, developers can elevate their web development skills to new heights, unlocking fresh possibilities and pushing the boundaries of what can be achieved on the web.
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.