TypeScript 5.0 was officially released on Mar 16, 2023, and is now available to everyone for use. This release introduces many new features with the aim of making TypeScript smaller, simpler, and faster.

This new release modernizes decorators for class customization, allowing for the customization of classes and their members in a reusable way. Developers can now add a const modifier to a type parameter declaration, allowing const-like inferences to be the default. The new release also makes all enums union enums, simplifying code structure and speeding up the TypeScript experience.

In this article, you will explore the changes introduced in TypeScript 5.0, providing an in-depth look at its new features and capabilities.

Getting Started with TypeScript 5.0

TypeScript is an official compiler you can install into your project using npm. If you want to start using TypeScript 5.0 in your project, you can run the following command in your project’s directory:

npm install -D typescript

This will install the compiler in the node_modules directory, which you can now run with the npx tsc command.

You can also find instructions on using the newer version of TypeScript in Visual Studio Code in this documentation.

What’s New in TypeScript 5.0?

In this article, let’s explore 5 major updates introduced into TypeScript. These features include:

Modernized Decorators

Decorators have been around in TypeScript for a while under an experimental flag, but the new release brings them up to speed with the ECMAScript proposal, which is now in stage 3, meaning it’s in a stage where it gets added to TypeScript.

Decorators are a way to customize the behavior of classes and their members in a reusable way. For example, if you have a class that has two methods, greet and getAge:

class Person {
    name: string;
    age: number;
    constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }

    greet() {
        console.log(`Hello, my name is ${this.name}.`);
    }

    getAge() {
        console.log(`I am ${this.age} years old.`);
    }
}

const p = new Person('Ron', 30);
p.greet();
p.getAge();

In real-world use cases, this class should have more complicated methods that handle some async logic and have side effects, e.t.c., where you would want to throw in some console.log calls to help debug the methods.

class Person {
    name: string;
    age: number;
    constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }

    greet() {
        console.log('LOG: Method Execution Starts.');
        console.log(`Hello, my name is ${this.name}.`);
        console.log('LOG: Method Execution Ends.');
    }

    getAge() {
        console.log('LOG: Method Execution Starts.');
        console.log(`I am ${this.age} years old.`);
        console.log('Method Execution Ends.');
    }
}

const p = new Person('Ron', 30);
p.greet();
p.getAge();

This is a frequently occurring pattern, and it would be convenient to have a solution to apply to every method.

This is where decorators come into play. We can define a function named debugMethod that appears as follows:

function debugMethod(originalMethod: any, context: any) {
    function replacementMethod(this: any, ...args: any[]) {
        console.log('Method Execution Starts.');
        const result = originalMethod.call(this, ...args);
        console.log('Method Execution Ends.');
        return result;
    }
    return replacementMethod;
}

In the code above, the debugMethod takes the original method (originalMethod) and returns a function that does the following:

  1. Logs a message “Method Execution Starts.”.
  2. Passes the original method and all its arguments (including this).
  3. Logs a message “Method Execution Ends.”.
  4. Returns whatever the original method returned.

By using decorators, you can apply the debugMethod to your methods as shown in the code below:

class Person {
    name: string;
    age: number;
    constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }
    @debugMethod
    greet() {
        console.log(`Hello, my name is ${this.name}.`);
    }
    @debugMethod
    getAge() {
        console.log(`I am ${this.age} years old.`);
    }
}
const p = new Person('Ron', 30);
p.greet();
p.getAge();

This will output the following:

LOG: Entering method.
Hello, my name is Ron.
LOG: Exiting method.
LOG: Entering method.
I am 30 years old.
LOG: Exiting method.

When defining the decorator function (debugMethod), a second parameter is passed called context (it’s the context object — has some useful information about how the decorated method was declared and also the name of the method). You can update your debugMethod to get the method name from the context object:

function debugMethod(
    originalMethod: any,
    context: ClassMethodDecoratorContext
) {
    const methodName = String(context.name);
    function replacementMethod(this: any, ...args: any[]) {
        console.log(`'${methodName}' Execution Starts.`);
        const result = originalMethod.call(this, ...args);
        console.log(`'${methodName}' Execution Ends.`);
        return result;
    }
    return replacementMethod;
}

When you run your code, the output will now carry the name of each method that is decorated with the debugMethod decorator:

'greet' Execution Starts.
Hello, my name is Ron.
'greet' Execution Ends.
'getAge' Execution Starts.
I am 30 years old.
'getAge' Execution Ends.

There is more to what you can do with decorators. Feel free to check the original pull request for more information on how to use decorators in TypeScript.

Introducing const Type Parameters

This is another big release that gives you a new tool with generics in order to improve the inference that you get when you call functions. By default, when you declare values with const, TypeScript infers the type and not its literal values:

// Inferred type: string[]
const names = ['John', 'Jake', 'Jack'];

Until now, to achieve the desired inference, you had to use the const assertion by adding “as const”:

// Inferred type: readonly ["John", "Jake", "Jack"]
const names = ['John', 'Jake', 'Jack'] as const;

When you call functions, it’s similar. In the code below, the inferred type of countries is string[]:

type HasCountries = { countries: readonly string[] };
function getCountriesExactly(arg: T): T['countries'] {
    return arg.countries;
}

// Inferred type: string[]
const countries = getCountriesExactly({ countries: ['USA', 'Canada', 'India'] });

You may desire a more specific type of which one way to fix before now has been to add the as const assertion:

// Inferred type: readonly ["USA", "Canada", "India"]
const names = getNamesExactly({ countries: ['USA', 'Canada', 'India'] } as const);

This can be difficult to remember and implement. However, TypeScript 5.0 introduces a new feature where you can add a const modifier to a type parameter declaration, which will automatically apply a const-like inference as default.

type HasCountries = { countries: readonly string[] };
function getNamesExactly(arg: T): T['countries'] {
    return arg.countries;
}

// Inferred type: readonly ["USA", "Canada", "India"]
const names = getNamesExactly({ countries: ['USA', 'Canada', 'India'] });

Using const type parameters allows developers to express intent more clearly in their code. If a variable is intended to be constant and never change, using a const type parameter ensures that it can never be changed accidentally.

You can check the original pull request for more information on how the const type parameter works in TypeScript.

Improvements to Enums

Enums in TypeScript are a powerful construct that allows developers to define a set of named constants. In TypeScript 5.0, improvements have been made to enums to make them even more flexible and useful.

For example, if you have the following enum passed into a function:

enum Color {
    Red,
    Green,
    Blue,
}

function getColorName(colorLevel: Color) {
    return colorLevel;
}

console.log(getColorName(1));

Before the introduction of TypeScript 5.0, you could pass a wrong level number, and it would throw no error. But with the introduction of TypeScript 5.0, it will immediately throw an error.

Also, the new release makes all enums into union enums by creating a unique type for each computed member. This enhancement allows for the narrowing of all enums and the referencing of their members as types:

enum Color {
    Red,
    Purple,
    Orange,
    Green,
    Blue,
    Black,
    White,
}

type PrimaryColor = Color.Red | Color.Green | Color.Blue;

function isPrimaryColor(c: Color): c is PrimaryColor {
    return c === Color.Red || c === Color.Green || c === Color.Blue;
}

console.log(isPrimaryColor(Color.White)); // Outputs: false
console.log(isPrimaryColor(Color.Red)); // Outputs: true

Performance Improvements of TypeScript 5.0

TypeScript 5.0 includes numerous significant changes in code structure, data structures, and algorithmic extensions. This has helped improve the entire TypeScript experience, from installation to execution, making it faster and more efficient.

For example, the difference between the package size of TypeScript 5.0 and 4.9 is quite impressive.

TypeScript was recently migrated from namespaces to modules, allowing it to leverage modern build tooling that can perform optimizations like scope hoisting. Also, removing some deprecated code has shaved off about 26.4 MB from TypeScript 4.9’s 63.8 MB package size.

TypeScript package size
TypeScript package size

Here are a few more interesting wins in speed and size between TypeScript 5.0 and 4.9:

Scenario Time or Size Relative to TS 4.9
material-ui build time 90%
TypeScript Compiler startup time 89%
Playwright build time 88%
TypeScript Compiler self-build time 87%
Outlook Web build time 82%
VS Code build time 80%
typescript npm Package Size 59%

Bundler Resolution for Better Module Resolution

When you write an import statement in TypeScript, the compiler needs to know what the import refers to. It does this using module resolution. For example, when you write import { a } from "moduleA", the compiler needs to know the definition of a in moduleA to check its use.

In TypeScript 4.7, two new options were added for the --module and moduleResolution settings: node16 and nodenext.

The purpose of these options was to more accurately represent the exact lookup rules for ECMAScript modules in Node.js. However, this mode has several restrictions that are not enforced by other tools.

For example, in an ECMAScript module in Node.js, any relative import must include a file extension for it to work correctly:

import * as utils from "./utils"; // Wrong 

import * as utils from "./utils.mjs"; // Correct

TypeScript has introduced a new strategy called “moduleResolution bundler.” This strategy can be implemented by adding the following code in the “compilerOptions” section of your TypeScript configuration file:

{
    "compilerOptions": {
        "target": "esnext",
        "moduleResolution": "bundler"
    }
}

This new strategy is suitable for those using modern bundlers such as Vite, esbuild, swc, Webpack, Parcel, and others that utilize a hybrid lookup strategy.

You can check the original pull request and its implementation for more information on how moduleResolution bundler works in TypeScript.

Deprecations

TypeScript 5.0 comes with some depreciation, including runtime requirements, lib.d.ts changes, and API breaking changes.

  1. Runtime Requirements: TypeScript now targets ECMAScript 2018, and the package sets a minimum engine expectation of 12.20. Therefore, users of Node.js should have a minimum version of 12.20 or later to use TypeScript 5.0.
  2. lib.d.ts Changes: There have been some changes to how types for the DOM are generated, which may affect existing code. In particular, certain properties have been converted from number to numeric literal types, and properties and methods for cut, copy, and paste event handling have been moved across interfaces.
  3. API breaking changes: Some unnecessary interfaces have been removed, and some correctness improvements have been made. TypeScript 5.0 has also moved to modules.

TypeScript 5.0 has deprecated certain settings and their corresponding values, including target: ES3, out, noImplicitUseStrict, keyofStringsOnly, suppressExcessPropertyErrors, suppressImplicitAnyIndexErrors, noStrictGenericChecks, charset, importsNotUsedAsValues, and preserveValueImports, as well as prepend in project references.

While these configurations will remain valid until TypeScript 5.5, a warning will be issued to alert users still using them.

Summary

In this article, you have learned some of the major features and improvements that TypeScript 5.0 brings, such as improvements to enums, bundler resolution, and const type parameters, along with improvements to speed and size.

If you’re thinking about TypeScript for your next projects, give Kinsta’s Application Hosting a try for free.

Now it’s your turn! What features or improvements do you find most appealing in TypeScript 5.0? Are there any significant ones that we may have overlooked? Let us know in the comments.

Joel Olawanle Kinsta

Joel is a Frontend developer working at Kinsta as a Technical Editor. He is a passionate teacher with love for open source and has written over 200 technical articles majorly around JavaScript and it's frameworks.