Oduah Chigozie Technical writer | Frontend developer | Blockchain developer

Exploring TypeScript 5 features: Smaller, simpler, faster

13 min read 3698 109

Exploring Typescript 5 Features Smaller Simpler Faster

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!

New features and enhancements in TypeScript 5

TypeScript 5 brings a range of new features and enhancements to the table. These include the following:

In the rest of this section, we’ll explain each feature and enhancement in the list in more detail.

Speed, memory, and optimizations

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:

  • 90 percent faster build time for MUI
  • 87 percent faster compiler startup time
  • 87 percent faster build time for Playwright
  • 87 percent faster self-build time for the compiler
  • 82 percent faster build time for Outlook Web
  • 80 percent faster build time for VS Code
  • 59% percent reduction in npm package size

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

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 parameters

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

Supporting multiple configuration files in 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.


More great articles from LogRocket:


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.

All enums are union enums

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

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";'

Support for 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) {
   // ...
}

Passing emit-specific flags under --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 JSDoc

TypeScript 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 JSDoc

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

Breaking changes and deprecations

TypeScript 5.0 introduced several breaking changes and deprecations. Let’s explore each of them in more detail.

Runtime requirements

TypeScript 5.0 requires a minimum Node.js version of 12.20 and targets ECMAScript 2018.

Implicit coercions in relational operators

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

Deprecated flags

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.

Conclusion

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.

: Full visibility into your web and mobile apps

LogRocket Dashboard Free Trial Banner

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.

Try it for free.
Oduah Chigozie Technical writer | Frontend developer | Blockchain developer

Leave a Reply