What's new in TypeScript 5.0 Beta
Another day, another version of our beloved JavaScript superset is out! On January 26th, 2023, Microsoft released the beta of TS 5.0, and this version carries some of the most interesting and most important features TypeScript has shipped in a while! Let’s find out what they are.
Installing the beta#
First things first, if you want to try any of the TS 5.0 beta features, don’t forget to install the npm package with the @beta tag, like this:
npm install typescript@betaThen check out this tutorial to set your VSCode to use the newer TypeScript version.
Decorators are finally stable#
For many years, TS used its own implementation of decorators, a proposal we’ve already covered here on the blog. With this proposal now promoted to stage 3 at TC39, you can finally use decorators without setting the --experimentalDecorators flag or the option of the same name in tsconfig.json.
To explain what a decorator is in short, imagine we have a class like this:
class Person { name: string; constructor(name: string) { this.name = name; }
greet() { console.log(`Hello, my name is ${this.name}.`); }}
const p = new Person("Ray");p.greet();And we want to log what happens inside the greet function for debugging purposes. The most common approach is usually to fill the code with console.log like this:
class Person { name: string; constructor(name: string) { this.name = name; }
greet() { console.log("LOG: Entering method.");
console.log(`Hello, my name is ${this.name}.`);
console.log("LOG: Exiting method.") }}It’s fairly common to do this pretty much everywhere, but imagine if we needed this for every single method. It would get pretty messy. And that’s where decorators come in.
Decorators are a meta feature found in several languages that lets you change the default behavior of functions and classes through an annotation in the @name format. A decorator is a regular function, but with a specific signature:
function debug (originalMethod: any, _context: any) { return function (this: any, ...args: any[]) { console.log(`[DEBUG] Entering method`) const result = originalMethod.call(this, ...args) console.log(`[DEBUG] Exiting method`) return result }}Think of it as a function that returns a replacement method that will be used in place of the original one. We’ll always return a function that takes two parameters, a this and args, which are the scope and the arguments of the original function.
An important note: arrow functions can’t have a
thisparameter because their scope is lexical, not logical, meaning the compiler will bindthisautomatically.
The outer function has two other parameters, the originalMethod, which is the original method passed by reference, and a context, which is an object holding several pieces of information about the decorated method, like its name and so on.
With that, we can update our original method to carry the following annotation:
class Person { name: string; constructor(name: string) { this.name = name; }
@debug greet() { console.log(`Hello, my name is ${this.name}.`); }}
const p = new Person("Ray");p.greet();
// Output://// [DEBUG] Entering method.// Hello, my name is Ray.// [DEBUG] Exiting method.And we can do the same for any other function. That makes decorators one of the most powerful proposals TypeScript has ever had.
But, as you probably noticed, we have an unused argument in the original function, the context. This object holds several pieces of information about the method that was called, and TS has a specific type for it, ClassMethodDecoratorContext. So let’s type our decorator the right way:
function debug (originalMethod: any, context: ClassMethodDecoratorContext) { const methodName = String(context.name) return function (this: any, ...args: any[]) { console.log(`[DEBUG] Entering method ${methodName}`) const result = originalMethod.call(this, ...args) console.log(`[DEBUG] Exiting method ${methodName}`) return result }}Notice that, besides logging when we enter and exit the method, we’re also logging the method’s name now. But that’s not all: the context also has a function called addInitializer, which we already covered in the article about decorators. This method gives us a way to hook into the start of the constructor (or into the class’s own static initialization block). A classic JS example:
class Person { name: string; constructor(name: string) { this.name = name; this.greet = this.greet.bind(this); }
greet() { console.log(`Hello, my name is ${this.name}.`); }}Another way to write this code is to initialize the greet method as an arrow function:
class Person { name: string; constructor(name: string) { this.name = name; }
greet = () => { console.log(`Hello, my name is ${this.name}.`); };}This pattern is heavily used when we want to make sure this won’t get rebound when we call greet outside its context. And we can do this through addInitializer, to have it call bind for us in every case:
function bound(originalMethod: any, context: ClassMethodDecoratorContext) { const methodName = context.name; if (context.private) { throw new Error(`'bound' cannot decorate private properties like ${methodName as string}.`); } context.addInitializer(function () { this[methodName] = this[methodName].bind(this); });}Notice that, in this decorator, we’re not returning a replacement method. That means we’ll leave the original method exactly as it is, and just bind this for the greet method. And we can stack more than one decorator on the same method without any issues:
class Person { name: string; constructor(name: string) { this.name = name; }
@bound @debug greet() { console.log(`Hello, my name is ${this.name}.`); }}
const p = new Person("Ray");const greet = p.greet;
greet();As you can see, the two decorators were stacked one on top of the other. It’s worth noting this, because they run in reverse order: @bound will decorate whatever @debug returns, and so on.
Think of them as being applied bottom to top.
Another note, if you’d rather, you can also put them on the same line:
@bound @loggedMethod greet() { console.log(`Hello, my name is ${this.name}.`); }To make things even more interesting, we can wrap a decorator so it becomes a decorator factory. For example, if we want to change the prefix of our log message:
function addLog (prefix = '[DEBUG]') { return function debug (originalMethod: any, _context: any) { const methodName = String(context.name) return function (this: any, ...args: any[]) { console.log(`${prefix} Entering method ${methodName}`) const result = originalMethod.call(this, ...args) console.log(`${prefix} Exiting method ${methodName}`) return result } }}Then we can use this decorator as a function:
class Person { name: string; constructor(name: string) { this.name = name; }
@addLog("") greet() { console.log(`Hello, my name is ${this.name}.`); }}
const p = new Person("Ray");p.greet();
// Output://// Entering method 'greet'.// Hello, my name is Ray.// Exiting method 'greet'.Decorators can be used on more than just methods. We can add them to properties, getters, setters, and even classes.
And what about --experimentalDecorators#
The TS team says the experimentalDecorators flag is sticking around for now, and there are no plans to remove it from the language anytime soon. This flag used to be super important before this proposal, and it was the only way we had to use decorators.
Using decorators without the flag will be entirely valid TypeScript or plain JS code. That said, the TC39 proposal (this one) isn’t compatible with the other flag, emitDecoratorMetadata, which let you add decorators to parameters. There is, however, an addition to the original TC39 proposal that proposes adding parameter support as well.
Typing decorators#
In the previous examples, we typed the debug, addLog and bound decorators in a simple, didactic way. But ideally, you’d type every part of the decorator with a fairly strict type.
Let’s take our debug example:
function debug (originalMethod: any, context: ClassMethodDecoratorContext) { const methodName = String(context.name) return function (this: any, ...args: any[]) { console.log(`[DEBUG] Entering method ${methodName}`) const result = originalMethod.call(this, ...args) console.log(`[DEBUG] Exiting method ${methodName}`) return result }}We have two really important parameters here that we need to type. The first is the original method, which is a function, and can be defined as a type with this signature:
type OriginalMethod<This, Args extends any[], Return> = (this: This, ...args: Args) => ReturnNotice that we’re separating input and output with the Return, This and Args generics. That way, we can pass along exactly what values we’re going to send into the function.
Then we can type our decorator like this:
type OriginalMethod< This, Args extends any[], Return> = (this: This, ...args: Args) => Return
function debug<This, Args extends any[], Return> ( originalMethod: OriginalMethod<This, Args, Return>, context: ClassMethodDecoratorContext<This, OriginalMethod<This, Args, Return>>) { const methodName = String(context.name) return function (this: This, ...args: Args): Return { console.log(`[DEBUG] Entering method ${methodName}`) const result = originalMethod.call(this, ...args) console.log(`[DEBUG] Exiting method ${methodName}`) return result }}And that’s how you properly type any decorator.
Const type parameters#
A pretty common use for TS types is to get the defined values for a list or a primitive. For example, when we have a function like this one:
const routerFactory = <T>(routes: T[]) => ({ reRoute(original:T, newRoute: T) { return newRoute }})The type we get from this function when we call it will infer that T is a string, so we’ll receive an array of strings and return a string. But we should only be able to redirect to routes that already exist, so we can create an array and pass those routes into the function:
const routerFactory = <T>(routes: T[]) => ({ reRoute(original:T, newRoute: T) { return newRoute }})
const router = routerFactory([ '/', '/about', '/contact', '/blog', '/blog/:id',])But we can still call our function with any string, because the type is still just resolving to string. That means we can pass anything at all:
router.reRoute('lkjkljklj', 'lkjlkjlkjlkj') // worksOne way out would be to use the as const modifier, but for that, our function would need to accept an options object with the routes, and we’d also have to remember to do that every single time we instantiate this feature. In 5.0, though, we can add a type annotation called const:
const routerFactory = <const T>(routes: T[]) => ({ reRoute(original:T, newRoute: T) { return newRoute }})
const router = routerFactory([ '/', '/about', '/contact', '/blog', '/blog/:id',])Now our routes will be typed as a union of every string in the array, ("/"|"/about"|"/contact"|"/blog"|"/blog/:id")[], so the internal strings need to be members of this array to be considered valid.
But keep in mind, you might be thinking of doing something like this:
const availableRoutes = [ '/', '/about', '/contact', '/blog', '/blog/:id',]
const routerFactory = <const T>(routes: T[]) => ({ reRoute(original:T, newRoute: T) { return newRoute }})
const router = routerFactory(availableRoutes)In this case, availableRoutes will be inferred as an array of strings. So the const inference won’t make any difference here. For this code to work like the previous one, we need to add as const to the array from the start:
const availableRoutes = [ '/', '/about', '/contact', '/blog', '/blog/:id',] as constBut we’ll also need to make a change to our function, since our array is no longer an array of strings but a union type, so it’s typed as a subtype of readonly string[]. So we have to tell the factory that:
const availableRoutes = [ '/', '/about', '/contact', '/blog', '/blog/:id',] as const
const routerFactory = <const T extends readonly string[]>(routes: T) => ({ reRoute(original:T, newRoute: T) { return newRoute }})
const router = routerFactory(availableRoutes)But now we’ll have a problem if we want to call reRoute, because T will be the array itself, not the individual strings inside it. For that, we need to go one level deeper and also type reRoute with a generic U, which will be one of the strings from T:
const availableRoutes = [ '/', '/about', '/contact', '/blog', '/blog/:id',] as const
const routerFactory = <const T extends readonly string[]>(routes: T) => ({ reRoute<const U extends T[number]>(original:U, newRoute: U) { return newRoute }})
const router = routerFactory(availableRoutes)Now we’ll get the same result if we call the function, meaning we need to pass two strings that are inside the array of available routes we sent in from the start.
All enums are unions now#
When we first started using enums in TS, they were nothing more than a plain list of numbers, each number tied in our hearts to a label, but as far as TypeScript was concerned, they were all just numbers. That meant an enum like this:
enum E { Foo = 10, Bar = 20,}Wouldn’t see any difference between receiving either value (foo or bar) inside a function, as long as the parameter’s type was E:
function takeValue(e: E) {}
takeValue(E.Foo); // workstakeValue(123); // error!TS 2.0 introduced string enums, which let us do a whole range of type manipulations, letting us filter, exclude, take a subset, and run several other operations to narrow down the accepted types, like I do, for example, in my enigma code.
That meant every enum made of strings was treated as a union of all its members’ strings, rather than just number. But when we had enums initialized by functions or values that couldn’t be computed at development time, TS ignored the newer implementation and fell back to the old one, losing all the benefits of the types:
enum E { Blah = Math.random()}In the new version of TS, the compiler is a lot smarter now, and it can infer the types of any enum as a union type!
Other changes#
- The
extendskey insidetsconfig.jsonnow supports multiple config files, letting you extend configs from several places at once - The value
bundleris now an option formoduleResolutionintsconfig.json, which models module resolution the way bundlers like webpack do - New custom flags to configure how each type of import behaves
- Support for
export type * as foo from 'package.ts' - Support for satisfies and
@overloadin JSDoc - Performance improvements of 80 to 90%, and a 58% reduction in package size (TS got smaller and faster, MUCH faster)
Check out the official docs on the TS site for a fuller list of the smaller changes, and then some!