- read

All JavaScript and TypeScript Features of the last 3 years

Linus Schlumberger 68

TypeScript as envisioned by Stable Diffusion

This article goes through almost all of the changes of the last 3 years (and some from earlier) in JavaScript / ECMAScript and TypeScript .

Not all of the following features will be relevant to you or even practical, but they should instead serve to show what’s possible and to deepen your understanding of these languages.

There are a lot of TypeScript features I left out because they can be summarized as “This didn’t work like you would expect it to, but now it does”. So if something didn’t work in the past, try it again now.

  • JavaScript / ECMAScript (oldest first)
  • TypeScript (oldest first)

Content

ECMAScript

Past (Still relevant older introductions)

  • Tagged template literals: By prepending a function name in front of a template literal, the function will be passed the parts of the template literals and the template values. This has some interesting uses.
// Let's say we want to write a way to log arbitrary strings containing a number but format the number.
// We can use tagged templates for that.
function formatNumbers(strings: TemplateStringsArray, number: number): string {
return strings[0] + number.toFixed(2) + strings[1];
}
console.log(formatNumbers`This is the value: ${0}, it's important.`); // This is the value: 0.00, it's important.

// Or if we wanted to "translate" (change to lowercase here) translation keys within strings.
function translateKey(key: string): string {
return key.toLocaleLowerCase();
}
function translate(strings: TemplateStringsArray, ...expressions: string[]): string {
return strings.reduce((accumulator, currentValue, index) => accumulator + currentValue + translateKey(expressions[index] ?? ''), '');
}
console.log(translate`Hello, this is ${'NAME'} to say ${'MESSAGE'}.`); // Hello, this is name to say message.

// Or if we wanted to "translate" (change to lowercase here) translation keys within strings.
function translateKey(key: string): string {
return key.toLocaleLowerCase();
}
function translate(strings: TemplateStringsArray, ...expressions: string[]): string {
return strings.reduce((accumulator, currentValue, index) => accumulator + currentValue + translateKey(expressions[index] ?? ''), '');
}
console.log(translate`Hello, this is ${'NAME'} to say ${'MESSAGE'}.`); // Hello, this is name to say message.
  • Symbols: Unique keys for objects: Symbol("foo") === Symbol("foo"); // false. Used internally.
const obj: { [index: string]: string } = {};

const symbolA = Symbol('a');
const symbolB = Symbol.for('b');

console.log(symbolA.description); // "a"

obj[symbolA] = 'a';
obj[symbolB] = 'b';
obj['c'] = 'c';
obj.d = 'd';

console.log(obj[symbolA]); // "a"
console.log(obj[symbolB]); // "b"

// The key cannot be accessed with any other symbols or without a symbol.
console.log(obj[Symbol('a')]); // undefined
console.log(obj['a']); // undefined

// The keys are not enumerated when using for ... in.
for (const i in obj) {
console.log(i); // "c", "d"
}

ES2020

  • Optional chaining: To access a value (via indexing) of a potentially undefined object, optional chaining can be used by using ? after the parent object name. This is also possible to use for indexing ([...]) or function calling.
// PREVIOUSLY:
// If we have an object variable (or any other structure) we don't know for certain is defined,
// We can not easily access the property.
const object: { name: string } | undefined = Math.random() > 0.5 ? undefined : { name: 'test' };
const value = object.name; // type error: 'object' is possibly 'undefined'.

// We could first check if it is defined, but this hurts readability and gets complex for nested objects.
const objectOld: { name: string } | undefined = Math.random() > 0.5 ? undefined : { name: 'test' };
const valueOld = objectOld ? objectOld.name : undefined;

// NEW:
// Instead we can use optional chaining.
const objectNew: { name: string } | undefined = Math.random() > 0.5 ? undefined : { name: 'test' };
const valueNew = objectNew?.name;

// This can also be used for indexing and functions.
const array: string[] | undefined = Math.random() > 0.5 ? undefined : ['test'];
const item = array?.[0];
const func: (() => string) | undefined = Math.random() > 0.5 ? undefined : () => 'test';
const result = func?.();
  • Nullish coalescing operator (??): Instead of using the || operator for conditionally assigning, the new ?? operator can be used. Instead of applying to all falsy values, it only applies to undefined and null.
const value: string | undefined = Math.random() > 0.5 ? undefined : 'test';

// PREVIOUSLY:
// When we want to conditionally assign something else if a value is undefined or null, we could use the "||" operator.
const anotherValue = value || 'hello';
console.log(anotherValue); // "test" or "hello"

// This works fine when using truthy values, but if we were to compare to 0 or an empty string, it would also apply.
const incorrectValue = '' || 'incorrect';
console.log(incorrectValue); // always "incorrect"
const anotherIncorrectValue = 0 || 'incorrect';
console.log(anotherIncorrectValue); // always "incorrect"

// NEW:
// Instead we can use the new nullish coalescing operator. It only applies to undefined and null values.
const newValue = value ?? 'hello';
console.log(newValue) // always "hello"

// Now falsy values are not replaced.
const correctValue = '' ?? 'incorrect';
console.log(correctValue); // always ""
const anotherCorrectValue = 0 ?? 'incorrect';
console.log(anotherCorrectValue); // always 0
  • import(): Dynamically import, just like import ... from ..., but at runtime and using variables.
let importModule;
if (shouldImport) {
importModule = await import('./module.mjs');
}
  • String.matchAll(): Get multiple matches of a regular expression including their capture groups, without using a loop.
const stringVar = 'testhello,testagain,';

// PREVIOUSLY:
// Only gets matches, but not their capture groups.
console.log(stringVar.match(/test([\w]+?),/g)); // ["testhello,", "testagain,"]

// Only gets one match, including its capture groups.
const singleMatch = stringVar.match(/test([\w]+?),/);
if (singleMatch) {
console.log(singleMatch[0]); // "testhello,"
console.log(singleMatch[1]); // "hello"
}

// Gets the same result, but is very unintuitive (the exec method saves the last index).
// Needs to be defined outside the loop (to save the state) and be global (/g),
// otherwise this will produce an infinite loop.
const regex = /test([\w]+?),/g;
let execMatch;
while ((execMatch = regex.exec(stringVar)) !== null) {
console.log(execMatch[0]); // "testhello,", "testagain,"
console.log(execMatch[1]); // "hello", "again"
}

// NEW:
// Regex needs to be global (/g), also doesn't make any sense otherwise.
const matchesIterator = stringVar.matchAll(/test([\w]+?),/g);
// Needs to be iterated or converted to an array (Array.from()), no direct indexing.
for (const match of matchesIterator) {
console.log(match[0]); // "testhello,", "testagain,"
console.log(match[1]); // "hello", "again"
}
  • Promise.allSettled(): Like Promise.all(), but waits for all Promises to finish and does not return on the first reject/throw. It makes handling all errors easier.
async function success1() {return 'a'}
async function success2() {return 'b'}
async function fail1() {throw 'fail 1'}
async function fail2() {throw 'fail 2'}

// PREVIOUSLY:
console.log(await Promise.all([success1(), success2()])); // ["a", "b"]
// but:
try {
await Promise.all([success1(), success2(), fail1(), fail2()]);
} catch (e) {
console.log(e); // "fail 1"
}
// Notice: We only catch one error and can't access the success values.

// PREVIOUS FIX (really suboptimal):
console.log(await Promise.all([ // ["a", "b", undefined, undefined]
success1().catch(e => { console.log(e); }),
success2().catch(e => { console.log(e); }),
fail1().catch(e => { console.log(e); }), // "fail 1"
fail2().catch(e => { console.log(e); })])); // "fail 2"

// NEW:
const results = await Promise.allSettled([success1(), success2(), fail1(), fail2()]);
const sucessfulResults = results
.filter(result => result.status === 'fulfilled')
.map(result => (result as PromiseFulfilledResult<string>).value);
console.log(sucessfulResults); // ["a", "b"]
results.filter(result => result.status === 'rejected').forEach(error => {
console.log((error as PromiseRejectedResult).reason); // "fail 1", "fail 2"
});
// OR:
for (const result of results) {
if (result.status === 'fulfilled') {
console.log(result.value); // "a", "b"
} else if (result.status === 'rejected') {
console.log(result.reason); // "fail 1", "fail 2"
}
}
  • BigInt: The new BigInt data type allows for accurately storing and operating on large (whole) numbers, which prevents errors produced by JavaScript storing numbers as floats. They can either be constructed using the BigInt() constructor (preferably with strings to prevent inaccuracies) or by appending n at the end of a number.
// PREVIOUSLY:
// JavaScript stores numbers as floats, so there is always a bit of inaccuracy
// but more importantly, there start to be inaccuracies for integer operations after a certain number.
const maxSafeInteger = 9007199254740991;
console.log(maxSafeInteger === Number.MAX_SAFE_INTEGER); // true

// If we compare numbers bigger than it, there can be inaccuracies.
console.log(Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2);

// NEW:
// Using the new BigInt datatype we can in theory store and operate on infinitely big (whole) numbers.
// Use it by using the BigInt constructor or appending "n" at the end of a number.
const maxSafeIntegerPreviously = 9007199254740991n;
console.log(maxSafeIntegerPreviously); // 9007199254740991

const anotherWay = BigInt(9007199254740991);
console.log(anotherWay); // 9007199254740991

// If we use the constructor using numbers, we can not safely pass integers bigger than the MAX_SAFE_INTEGER.
const incorrect = BigInt(9007199254740992);
console.log(incorrect); // 9007199254740992
const incorrectAgain = BigInt(9007199254740993);
console.log(incorrectAgain); // 9007199254740992
// Oops, they convert to the same value.

// Instead use strings or better the other syntax.
const correct = BigInt('9007199254740993');
console.log(correct); // 9007199254740993
const correctAgain = 9007199254740993n;
console.log(correctAgain); // 9007199254740993

// hex, octal and binary numbers can also be passed as strings.
const hex = BigInt('0x1fffffffffffff');
console.log(hex); // 9007199254740991
const octal = BigInt('0o377777777777777777');
console.log(octal); // 9007199254740991
const binary = BigInt('0b11111111111111111111111111111111111111111111111111111');
console.log(binary); // 9007199254740991

// Most arithmetic operations work just like you would expect them to,
// though the other operator needs to also be a BigInt. All operations return BigInts as well.
const addition = maxSafeIntegerPreviously + 2n;
console.log(addition); // 9007199254740993

const multiplication = maxSafeIntegerPreviously * 2n;
console.log(multiplication); // 18014398509481982

const subtraction = multiplication - 10n;
console.log(subtraction); // 18014398509481972

const modulo = multiplication % 10n;
console.log(modulo); // 2

const exponentiation = 2n ** 54n;
console.log(exponentiation); // 18014398509481984

const exponentiationAgain = 2n^54n;
console.log(exponentiationAgain); // 18014398509481984

const negative = exponentiation * -1n;
console.log(negative); // -18014398509481984

// Division works a bit differently since BigInt can only store whole numbers.
const division = multiplication / 2n;
console.log(division); // 9007199254740991
// For whole numbers that are divisible, this works just fine.

// But for numbers that are not divisible, this will act like integer division (rounded down).
const divisionAgain = 5n / 2n;
console.log(divisionAgain); // 2

// There is no strict equality (but loose equality) to non-BigInt numbers.
console.log(0n === 0); // false
console.log(0n == 0); // true

// But comparisons work as expected.
console.log(1n < 2); // true
console.log(2n > 1); // true
console.log(2 > 2); // false
console.log(2n > 2); // false
console.log(2n >= 2); // true

// They are of the type "bigint"
console.log(typeof 1n); // "bigint"

// They can be converted back to regular numbers (signed and unsigned (no negative numbers)).
// Though this of course sacrifices the accuracy. The number of significant digits can be specified.

console.log(BigInt.asIntN(0, -2n)); // 0
console.log(BigInt.asIntN(1, -2n)); // 0
console.log(BigInt.asIntN(2, -2n)); // -2
// Usually you would use a higher number of bits.

// Negative numbers will be converted to the 2's-complement when converting to an unsigned number.
console.log(BigInt.asUintN(8, -2n)); // 254
  • globalThis: Access variables in the global context, regardless of the environment (browser, NodeJS, …). Still considered bad practice, but sometimes necessary. Akin to this at the top level in the browser.
console.log(globalThis.Math); // Math Object
  • import.meta: When using ES-modules, get the current module URL import.meta.url.
console.log(import.meta.url); // "file://..."
  • export * as … from …: Easily re-export defaults as submodules.
export * as am from 'another-module'
import { am } from 'module'

ES2021

  • String.replaceAll(): Replace all instances of a substring in a string, instead of always using a regular expression with the global flag (/g).
const testString = 'hello/greetings everyone/everybody';
// PREVIOUSLY:
// Only replaces the first instance
console.log(testString.replace('/', '|')); // 'hello|greetings everyone/everybody'

// Instead a regex needed to be used, which is worse for performance and needs escaping.
// Not the global flag (/g).
console.log(testString.replace(/\//g, '|')); // 'hello|greetings everyone|everybody'

// NEW:
// Using replaceAll this is much clearer and faster.
console.log(testString.replaceAll('/', '|')); // 'hello|greetings everyone|everybody'
  • Promise.any(): When only one result of a list of promises is needed, it returns the first result, it only rejects when all promises are rejected and returns an AggregateError, instead of Promise.race, which instantly rejects.
console.log(await Promise.race([success1(), success2()])); // "a"
// but:
try {
await Promise.race([fail1(), fail2(), success1(), success2()]);
} catch (e) {
console.log(e); // "fail 1"
}
// Notice: We only catch one error and can't access the success value.

// PREVIOUS FIX (really suboptimal):
console.log(await Promise.race([ // "a"
fail1().catch(e => { console.log(e); }), // "fail 1"
fail2().catch(e => { console.log(e); }), // "fail 2"
success1().catch(e => { console.log(e); }),
success2().catch(e => { console.log(e); })]));

// NEW:
console.log(await Promise.any([fail1(), fail2(), success1(), success2()])); // "a"
// And it only rejects when all promises reject and returns an AggregateError containing all the errors.
try {
await Promise.any([fail1(), fail2()]);
} catch (e) {
console.log(e); // [AggregateError: All promises were rejected]
console.log(e.errors); // ["fail 1", "fail 2"]
}
  • Nullish coalescing assignment (??=): Only assign a value when it was “nullish” before (null or undefined).
let x1 = undefined;
let x2 = 'a';
const getNewValue = () => 'b';

// Assigns the new value to x1, because undefined is nullish.
x1 ??= 'b';
console.log(x1) // "b"

// Does not assign a new value to x2, because a string is not nullish.
// Also note: getNewValue() is never executed.
x2 ??= getNewValue();
console.log(x2) // "a"
  • Logical and assignment (&&=): Only assign a value when it was “truthy” before (true or a value that converts to true).
let x1 = undefined;
let x2 = 'a';
const getNewValue = () => 'b';

// Does not assign a new value to x1, because undefined is not truthy.
// Also note: getNewValue() is never executed.
x1 &&= getNewValue();
console.log(x1) // undefined

// Assigns a new value to x2, because a string is truthy.
x2 &&= 'b';
console.log(x2) // "b"
  • Logical or assignment (||=): Only assign a value when it was “falsy” before (false or converts to false).
let x1 = undefined;
let x2 = 'a';
const getNewValue = () => 'b';

// Assigns the new value to x1, because undefined is falsy.
x1 ||= 'b';
console.log(x1) // "b"

// Does not assign a new value to x2, because a string is not falsy.
// Also note: getNewValue() is never executed.
x2 ||= getNewValue();
console.log(x2) // "a"
  • WeakRef: Hold a “weak” reference to an object, without preventing the object from being garbage-collected.
const ref = new WeakRef(element);

// Get the value, if the object/element still exists and was not garbage-collected.
const value = ref.deref;
console.log(value); // undefined
// Looks like the object does not exist anymore.
  • Numeric literal separators (_): Separate numbers using _ for better readability. This does not affect functionality.
const int = 1_000_000_000;
const float = 1_000_000_000.999_999_999;
const max = 9_223_372_036_854_775_807n;
const binary = 0b1011_0101_0101;
const octal = 0o1234_5670;
const hex = 0xD0_E0_F0;

ES2022

  • Top-level await: The await keyword can now be used at the top level of ES modules, eliminating the need for a wrapper function and improving error handling.
async function asyncFuncSuccess() {
return 'test';
}

async function asyncFuncFail() {
throw new Error('Test');
}

// PREVIOUSLY:
// Whenever we want to await a promise, this is only possible inside async functions.
// await asyncFuncSuccess(); // SyntaxError: await is only valid in async functions
// So we had to wrap it inside one, thereby losing error handling and top-level concurrency.
try {
(async () => {
console.log(await asyncFuncSuccess()); // "test"
try {
await asyncFuncFail();
} catch (e) {
// This is needed because otherwise the errors are never caught (or way too late without a proper trace).
console.error(e); // Error: "Test"
throw e;
}
})();
} catch (e) {
// This is never triggered (or way too late without a proper trace) because the function is async.
console.error(e);
}

// This is logged before the promise result because the async function is not awaited (because it couldn't).
console.log('Hey'); // "Hey"

// NEW:
// If the file is an ES module (set in package.json, has exports, named ".mts") we can just await at the top level instead.
console.log(await asyncFuncSuccess()); // "test"
try {
await asyncFuncFail();
} catch (e) {
console.error(e); // Error: "Test"
}

// This is logged after the promise result because all async calls are awaited.
console.log('Hello'); // "Hello"
  • #private: Make class members (properties and methods) private by naming them starting with #. These then can only be accessed from the class itself. They can not be deleted or dynamically assigned. Any incorrect behavior will result in a JavaScript (not TypeScript) syntax error. This is not recommended for TypeScript projects, instead just use the existing private keyword.
class ClassWithPrivateField {
#privateField;
#anotherPrivateField = 4;

constructor() {
this.#privateField = 42; // Valid
this.#privateField; // Syntax error
this.#undeclaredField = 444; // Syntax error
console.log(this.#anotherPrivateField); // 4
}
}

const instance = new ClassWithPrivateField();
instance.#privateField === 42; // Syntax error
  • static class members: Mark any class fields (properties and methods) as static.
class Logger {
static id = 'Logger1';
static type = 'GenericLogger';
static log(message: string | Error) {
console.log(message);
}
}

class ErrorLogger extends Logger {
static type = 'ErrorLogger';
static qualifiedType;
static log(e: Error) {
return super.log(e.toString());
}
}

console.log(Logger.type); // "GenericLogger"
Logger.log('Test'); // "Test"

// The instantiation of static-only classes is useless and only done here for demonstration purposes.
const log = new Logger();

ErrorLogger.log(new Error('Test')); // Error: "Test" (not affected by instantiation of the parent)
console.log(ErrorLogger.type); // "ErrorLogger"
console.log(ErrorLogger.qualifiedType); // undefined
console.log(ErrorLogger.id); // "Logger1"

// This throws because log() is not an instance method but a static method.
console.log(log.log()); // log.log is not a function
  • static initialization blocks in classes: Block which is run when a class is initialized, basically the “constructor” for static members.
class Test {
static staticProperty1 = 'Property 1';
static staticProperty2;
static {
this.staticProperty2 = 'Property 2';
}
}

console.log(Test.staticProperty1); // "Property 1"
console.log(Test.staticProperty2); // "Property 2"
  • Import Assertions (non-standard, implemented in V8): Assert which type an import is using import ... from ... assert { type: 'json' }. Can be used to directly import JSON without having to parse it.
import json from './foo.json' assert { type: 'json' };
console.log(json.answer); // 42
  • RegExp match indices: Get the start and end indexes of regular expression matches and capture groups. This works for RegExp.exec(), String.match() and String.matchAll().
const matchObj = /(test+)(hello+)/d.exec('start-testesthello-stop');

// PREVIOUSLY:
console.log(matchObj?.index);

// NEW:
if (matchObj) {
// Start and end index of the entire match (before we only had the start).
console.log(matchObj.indices[0]); // [9, 18]

// Start and end indexes of capture groups.
console.log(matchObj.indices[1]); // [9, 13]
console.log(matchObj.indices[2]); // [13, 18]
}
  • Negative indexing (.at(-1)): When indexing an array or a string, at() can be used to index from the end. It's equivalent to arr[arr.length - 1] for getting a value (but not setting).
console.log([4, 5].at(-1)) // 5

const array = [4, 5];
array.at(-1) = 3; // SyntaxError: Assigning to rvalue
  • hasOwn: Recommended new way to find out which properties an object has instead of using obj.hasOwnProperty(). It works better for some edge cases.
const obj = { name: 'test' };

console.log(Object.hasOwn(obj, 'name')); // true
console.log(Object.hasOwn(obj, 'gender')); // false
  • Error cause: An optional cause can now be specified for Errors, which allows specifying of the original error when re-throwing it.
try {
try {
connectToDatabase();
} catch (err) {
throw new Error('Connecting to database failed.', { cause: err });
}
} catch (err) {
console.log(err.cause); // ReferenceError: connectToDatabase is not defined
}

Future (can already be used with TypeScript 4.9)

  • Auto-Accessor: Automatically make a property private and create get/set accessors for it.
class Person {
accessor name: string;

constructor(name: string) {
this.name = name;
console.log(this.name) // 'test'
}
}

const person = new Person('test');

TypeScript

Basics (Context for further introductions)

  • Generics: Pass through types to other types. This allows for types to be generalized but still typesafe. Always prefer this over using any or unknown.
// WITHOUT:
function getFirstUnsafe(list: any[]): any {
return list[0];
}

const firstUnsafe = getFirstUnsafe(['test']); // typed as any

// WITH:
function getFirst<Type>(list: Type[]): Type {
return list[0];
}

const first = getFirst<string>(['test']); // typed as string

// In this case the parameter can even be dropped because it is inferred from the argument.
const firstInferred = getFirst(['test']); // typed as string

// The types accepted as generics can also be limited using `extends`. The Type is also usually shortened to T.
class List<T extends string | number> {
private list: T[] = [];

get(key: number): T {
return this.list[key];
}

push(value: T): void {
this.list.push(value);
}
}

const list = new List<string>();
list.push(9); // Type error: Argument of type 'number' is not assignable to parameter of type 'string'.
const booleanList = new List<boolean>(); // Type error: Type 'boolean' does not satisfy the constraint 'string | number'.

Past (Still relevant older introductions)

  • Utility Types: TypeScript contains many utility types, some of the most useful are explained here.
interface Test {
name: string;
age: number;
}

// The Partial utility type makes all properties optional.
type TestPartial = Partial<Test>; // typed as { name?: string | undefined; age?: number | undefined; }
// The Required utility type does the opposite.
type TestRequired = Required<TestPartial>; // typed as { name: string; age: number; }
// The Readonly utility type makes all properties readonly.
type TestReadonly = Readonly<Test>; // typed as { readonly name: string; readonly age: string }
// The Record utility type allows the simple definition of objects/maps/dictionaries. It is preferred to index signatures whenever possible.
const config: Record<string, boolean> = { option: false, anotherOption: true };
// The Pick utility type gets only the specified properties.
type TestLess = Pick<Test, 'name'>; // typed as { name: string; }
type TestBoth = Pick<Test, 'name' | 'age'>; // typed as { name: string; age: string; }
// The Omit utility type ignores the specified properties.type
type TestFewer = Omit<Test, 'name'>; // typed as { age: string; }
type TestNone = Omit<Test, 'name' | 'age'>; // typed as {}
// The Parameters utility type gets the parameters of a function type.
function doSmth(value: string, anotherValue: number): string {
return 'test';
}
type Params = Parameters<typeof doSmth>; // typed as [value: string, anotherValue: number]
// The ReturnType utility type gets the return type of a function type.
type Return = ReturnType<typeof doSmth>; // typed as string

// There are many more, some of which are introduced further down.
  • Conditional Types: Conditionally set a type based on if some type matches / extends another type. They can be read in the same way as the conditional (ternary) operator in JavaScript.
// Only extracts the array type if it is an array, otherwise returns the same type.
type Flatten<T> = T extends any[] ? T[number] : T;

// Extracts out the element type.
type Str = Flatten<string[]>; // typed as string

// Leaves the type alone.
type Num = Flatten<number>; // typed as number
  • Inferring with conditional types: Not all generic types need to be specified by the consumer, some can also be inferred from the code. To have conditional logic based on inferred types, the infer keyword is needed. It in a way defines temporary inferred type variables.
// Starting with the previous example, this can be written more cleanly.
type FlattenOld<T> = T extends any[] ? T[number] : T;

// Instead of indexing the array, we can just infer the Item type from the array.
type Flatten<T> = T extends (infer Item)[] ? Item : T;

// If we wanted to write a type that gets the return type of a function and otherwise is undefined, we could also infer that.
type GetReturnType<Type> = Type extends (...args: any[]) => infer Return ? Return : undefined;

type Num = GetReturnType<() => number>; // typed as number

type Str = GetReturnType<(x: string) => string>; // typed as string

type Bools = GetReturnType<(a: boolean, b: boolean) => void>; // typed as undefined
  • Tuple Optional Elements and Rest: Declare optional elements in tuples using ? and the rest based on another type using ....
// If we don't yet know how long a tuple is going to be, but it's at least one, we can specify optional types using `?`.
const list: [number, number?, boolean?] = [];
list[0] // typed as number
list[1] // typed as number | undefined
list[2] // typed as boolean | undefined
list[3] // Type error: Tuple type '[number, (number | undefined)?, (boolean | undefined)?]' of length '3' has no element at index '3'.

// We could also base the tuple on an existing type.
// If we want to pad an array at the start, we could do that using the rest operator `...`.
function padStart<T extends any[]>(arr: T, pad: string): [string, ...T] {
return [pad, ...arr];
}

const padded = padStart([1, 2], 'test'); // typed as [string, number, number]
  • abstract Classes and methods: Classes and the methods within them can be declared as abstract to prevent them from being instantiated.
abstract class Animal {
abstract makeSound(): void;

move(): void {
console.log('roaming the earth...');
}
}

// Abstract methods need to be implemented when extended.
class Cat extends Animal {} // Compile error: Non-abstract class 'Cat' does not implement inherited abstract member 'makeSound' from class 'Animal'.

class Dog extends Animal {
makeSound() {
console.log('woof');
}
}

// Abstract classes cannot be instantiated (like Interfaces), and abstract methods cannot be called.
new Animal(); // Compile error: Cannot create an instance of an abstract class.

const dog = new Dog().makeSound(); // "woof"
  • Constructor signatures: Define the typing of constructors outside of Class declarations. Should not be used in most cases, abstract classes can be used instead.
interface MyInterface {
name: string;
}

interface ConstructsMyInterface {
new(name: string): MyInterface;
}

class Test implements MyInterface {
name: string;
constructor(name: string) {
this.name = name;
}
}

class AnotherTest {
age: number;
}

function makeObj(n: ConstructsMyInterface) {
return new n('hello!');
}

const obj = makeObj(Test); // typed as Test
const anotherObj = makeObj(AnotherTest); // Type error: Argument of type 'typeof AnotherTest' is not assignable to parameter of type 'ConstructsMyInterface'.
  • ConstructorParameters Utility Type: TypeScript helper function which gets the constructor parameters from a constructor type (but not a class).
// What if we wanted to get the constructor argument for our makeObj function?
interface MyInterface {
name: string;
}

interface ConstructsMyInterface {
new(name: string): MyInterface;
}

class Test implements MyInterface {
name: string;
constructor(name: string) {
this.name = name;
}
}

function makeObj(test: ConstructsMyInterface, ...args: ConstructorParameters<ConstructsMyInterface>) {
return new test(...args);
}

makeObj(Test); // Type error: Expected 2 arguments, but got 1.
const obj = makeObj(Test, 'test'); // typed as Test

TypeScript 4.0

  • Variadic Tuple Types: Rest elements in tuples can now be generic. The use of multiple rest elements is now also allowed.
// What if we had a function that combines two tuples of undefined length and types? How can we define the return type?

// PREVIOUSLY:
// We could write some overloads.
declare function concat(arr1: [], arr2: []): [];
declare function concat<A>(arr1: [A], arr2: []): [A];
declare function concat<A, B>(arr1: [A], arr2: [B]): [A, B];
declare function concat<A, B, C>(arr1: [A], arr2: [B, C]): [A, B, C];
declare function concat<A, B, C, D>(arr1: [A], arr2: [B, C, D]): [A, B, C, D];
declare function concat<A, B>(arr1: [A, B], arr2: []): [A, B];
declare function concat<A, B, C>(arr1: [A, B], arr2: [C]): [A, B, C];
declare function concat<A, B, C, D>(arr1: [A, B], arr2: [C, D]): [A, B, C, D];
declare function concat<A, B, C, D, E>(arr1: [A, B], arr2: [C, D, E]): [A, B, C, D, E];
declare function concat<A, B, C>(arr1: [A, B, C], arr2: []): [A, B, C];
declare function concat<A, B, C, D>(arr1: [A, B, C], arr2: [D]): [A, B, C, D];
declare function concat<A, B, C, D, E>(arr1: [A, B, C], arr2: [D, E]): [A, B, C, D, E];
declare function concat<A, B, C, D, E, F>(arr1: [A, B, C], arr2: [D, E, F]): [A, B, C, D, E, F];
// Even just for three items each, this is really suboptimal.

// Instead we could combine the types.
declare function concatBetter<T, U>(arr1: T[], arr2: U[]): (T | U)[];
// But this types to (T | U)[]

// NEW:
// With variadic tuple types, we can define it easily and keep the information about the length.
declare function concatNew<T extends Arr, U extends Arr>(arr1: T, arr2: U): [...T, ...U];

const tuple = concatNew([23, 'hey', false] as [number, string, boolean], [5, 99, 20] as [number, number, number]);
console.log(tuple[0]); // 23
const element: number = tuple[1]; // Type error: Type 'string' is not assignable to type 'number'.
console.log(tuple[6]); // Type error: Tuple type '[23, "hey", false, 5, 99, 20]' of length '6' has no element at index '6'.
  • Labeled Tuple Elements: Tuple elements can now be named like [start: number, end: number]. If one of the elements is named, all of them must be named.
type Foo = [first: number, second?: string, ...rest: any[]];

// This allows the arguments to be named correctly here, it also shows up in the editor.
declare function someFunc(...args: Foo);
  • Class Property Inference from Constructors: When a property is set in the constructor, the type can now be inferred and no longer needs to be set manually.
class Animal {
// No need to set types when they are assigned in the constructor.
name;

constructor(name: string) {
this.name = name;
console.log(this.name); // typed as string
}
}
  • JSDoc @deprecated Support: The JSDoc/TSDoc @deprecated tag is now recognized by TypeScript.
/** @deprecated message */
type Test = string;

const test: Test = 'dfadsf'; // Type error: 'Test' is deprecated.

TypeScript 4.1

  • Template Literal Types: When defining literal types, types can be specified through templating like ${Type}. This allows the construction of complex string types, for example when combining multiple string literals.
type VerticalDirection = 'top' | 'bottom';
type HorizontalDirection = 'left' | 'right';
type Direction = `${VerticalDirection} ${HorizontalDirection}`;

const dir1: Direction = 'top left';
const dir2: Direction = 'left'; // Type error: Type '"left"' is not assignable to type '"top left" | "top right" | "bottom left" | "bottom right"'.
const dir3: Direction = 'left top'; // Type error: Type '"left top"' is not assignable to type '"top left" | "top right" | "bottom left" | "bottom right"'.

// This can also be combined with generics and the new utility types.
declare function makeId<T extends string, U extends string>(first: T, second: U): `${Capitalize<T>}-${Lowercase<U>}`;
  • Key Remapping in Mapped Types: Retype mapped types while still using their values like [K in keyof T as NewKeyType]: T[K].
// Let's say we wanted to reformat an object but prepend its IDs with an underscore.
const obj = { value1: 0, value2: 1, value3: 3 };
const newObj: { [Property in keyof typeof obj as `_${Property}`]: number }; // typed as { _value1: number; _value2: number; value3: number; }
  • Recursive Conditional Types: Use conditional types inside of their definitions themselves. This allows for types that conditionally unpack an infinitely nested value.
type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T;

type P1 = Awaited<string>; // typed as string
type P2 = Awaited<Promise<string>>; // typed as string
type P3 = Awaited<Promise<Promise<string>>>; // typed as string
  • Editor support for JSDOC @see tag: The JSDoc/TSDoc @see variable/type/link tag is now supported in editors.
const originalValue = 1;
/**
* Copy of another value
* @see originalValue
*/
const value = originalValue;
  • tsc --explainFiles: The --explainFiles option can be used for the TypeScript CLI to explain which files are part of the compilation and why. This can be useful for debugging. Warning: For large projects or complex setups this will generate a lot of output, instead use tsc --explainFiles | less or something similar.
tsc --explainFiles

<<output
../../.asdf/installs/nodejs/16.13.1/.npm/lib/node_modules/typescript/lib/lib.es5.d.ts
Library referenced via 'es5' from file '../../.asdf/installs/nodejs/16.13.1/.npm/lib/node_modules/typescript/lib/lib.es2015.d.ts'
Library referenced via 'es5' from file '../../.asdf/installs/nodejs/16.13.1/.npm/lib/node_modules/typescript/lib/lib.es2015.d.ts'
../../.asdf/installs/nodejs/16.13.1/.npm/lib/node_modules/typescript/lib/lib.es2015.d.ts
Library referenced via 'es2015' from file '../../.asdf/installs/nodejs/16.13.1/.npm/lib/node_modules/typescript/lib/lib.es2016.d.ts'
Library referenced via 'es2015' from file '../../.asdf/installs/nodejs/16.13.1/.npm/lib/node_modules/typescript/lib/lib.es2016.d.ts'
../../.asdf/installs/nodejs/16.13.1/.npm/lib/node_modules/typescript/lib/lib.es2016.d.ts
Library referenced via 'es2016' from file '../../.asdf/installs/nodejs/16.13.1/.npm/lib/node_modules/typescript/lib/lib.es2017.d.ts'
Library referenced via 'es2016' from file '../../.asdf/installs/nodejs/16.13.1/.npm/lib/node_modules/typescript/lib/lib.es2017.d.ts'
...
output
  • Destructured Variables Can Be Explicitly Marked as Unused: When destructuring, an underscore can be used to mark a variable as unused. This prevents TypeScript from throwing an “unused variable” error.
const [_first, second] = [3, 5];
console.log(second);

// Or even shorter
const [_, value] = [3, 5];
console.log(value);

TypeScript 4.3

  • Separate Write Types on Properties: When defining set/get accessors, the write/set type can now be different than the read/get type. This allows for setters that accept multiple formats of the same value.
class Test {
private _value: number;

get value(): number {
return this._value;
}

set value(value: number | string) {
if (typeof value === 'number') {
this._value = value;
return;
}
this._value = parseInt(value, 10);
}
}
  • override: Explicitly mark inherited class methods as overrides using override, so when the parent class changes, TypeScript can notify you that the parent method no longer exists. This allows for safer complex inheritance patterns.
class Parent {
getName(): string {
return 'name';
}
}

class NewParent {
getFirstName(): string {
return 'name';
}
}

class Test extends Parent {
override getName(): string {
return 'test';
}
}

class NewTest extends NewParent {
override getName(): string { // Type error: This member cannot have an 'override' modifier because it is not declared in the base class 'NewParent'.
return 'test';
}
}
  • static Index Signatures: When using static properties on a Class, index signatures can now also be set using static [propName: string]: string.
// PREVIOUSLY:
class Test {}

Test.test = ''; // Type error: Property 'test' does not exist on type 'typeof Test'.

// NEW:
class NewTest {
static [key: string]: string;
}

NewTest.test = '';
  • Editor Support for JSDOC @link Tags: The JSDoc/TSDoc {@link variable/type/link} inline tag is now supported and will show up and resolve in editors.
const originalValue = 1;
/**
* Copy of {@link originalValue}
*/
const value = originalValue;

TypeScript 4.4

  • Exact Optional Property Types ( --exactOptionalPropertyTypes): Using the compiler flag --exactOptionalPropertyTypes (or in tsconfig.json) assignments as undefined are no longer allowed for properties which implicitly allow undefined (for example property?: string). Instead, undefined needs to explicitly be allowed like property: string | undefined.
class Test {
name?: string;
age: number | undefined;
}

const test = new Test();
test.name = undefined; // Type error: Type 'undefined' is not assignable to type 'string' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the type of the target.
test.age = undefined;
console.log(test.age); // undefined

TypeScript 4.5

  • The Awaited<> Type and Promise Improvements: The new Awaited<> utility type extracts the value type from infinitely nested Promises (like await does for the value). This also improved the type inference for Promise.all().
// Let's say we want to have a generic awaited value.
// We can use the Awaited utility type for this (its source code was part of a previous example),
// so infinitely nested Promises all resolve to their value.
type P1 = Awaited<string>; // typed as string
type P2 = Awaited<Promise<string>>; // typed as string
type P3 = Awaited<Promise<Promise<string>>>; // typed as string
  • type Modifiers on Import Names: Inside normal (not import type) import statements, the type keyword can be used to signal that the value should only be imported for type compilation (and can be stripped away).
// PREVIOUSLY:
// The optimal way to import types is to use the `import type` keyword to prevent them from actually being imported after compilation.
import { something } from './file';
import type { SomeType } from './file';
// This needs two import statements for the same file.

// NEW:
// Now this can be combined into one statement.
import { something, type SomeType } from './file';
  • const Assertions: When defining constants, as const can be used to accurately type them as literal types. This has a lot of use cases and makes accurate typings easier. It also makes objects and arrays readonly, which prevents mutations of constant objects.
// PREVIOUSLY:
const obj = { name: 'foo', value: 9, toggle: false }; // typed as { name: string; value: number; toggle: boolean; }
// Any value can be assigned because they are generally typed.
obj.name = 'bar';

const tuple = ['name', 4, true]; // typed as (string | number | boolean)[]
// The length and exact type can not be determined from the type. Any values can be assigned anywhere.
tuple[0] = 0;
tuple[3] = 0;

// NEW:
const objNew = { name: 'foo', value: 9, toggle: false } as const; // typed as { readonly name: "foo"; readonly value: 9; readonly toggle: false; }
// No value can be assigned (because it is defined as "foo" (and also is readonly)).
objNew.name = 'bar'; // type error: Cannot assign to 'name' because it is a read-only property.

const tupleNew = ['name', 4, true] as const; // typed as readonly ["name", 4, true]
// The length and exact type are now defined and nothing can be assigned (because it is defined as literals (and also is readonly)).
tupleNew[0] = 0; // type error: Cannot assign to '0' because it is a read-only property.
tupleNew[3] = 0; // type error: Index signature in type 'readonly ["name", 4, true]' only permits reading.
  • Snippet Completions for Methods in Classes: When a class inherits method types, they are now suggested as snippets in editors.

TypeScript 4.6

  • Indexed Access Inference Improvements: When directly indexing a Type with a key, the type will now be more accurate when it’s on the same object. Also, just a good example to show what is possible with modern TypeScript.
interface AllowedTypes {
'number': number;
'string': string;
'boolean': boolean;
}

// The Record specifies the kind and value type from the allowed types.
type UnionRecord<AllowedKeys extends keyof AllowedTypes> = { [Key in AllowedKeys]:
{
kind: Key;
value: AllowedTypes[Key];
logValue: (value: AllowedTypes[Key]) => void;
}
}[AllowedKeys];

// The function logValue only accepts the value of the Record.
function processRecord<Key extends keyof AllowedTypes>(record: UnionRecord<Key>) {
record.logValue(record.value);
}

processRecord({
kind: 'string',
value: 'hello!',

// The value used to implicitly have the type string | number | boolean,
// but now is correctly inferred to just string.
logValue: value => {
console.log(value.toUpperCase());
}
});
  • TypeScript Trace Analyzer ( --generateTrace): The --generateTrace <Output folder> option can be used for the TypeScript CLI to generate a file containing details regarding the type checking and compilation process. This can help optimize complex types.
tsc --generateTrace trace

cat trace/trace.json
<<output
[
{"name":"process_name","args":{"name":"tsc"},"cat":"__metadata","ph":"M","ts":...,"pid":1,"tid":1},
{"name":"thread_name","args":{"name":"Main"},"cat":"__metadata","ph":"M","ts":...,"pid":1,"tid":1},
{"name":"TracingStartedInBrowser","cat":"disabled-by-default-devtools.timeline","ph":"M","ts":...,"pid":1,"tid":1},
{"pid":1,"tid":1,"ph":"B","cat":"program","ts":...,"name":"createProgram","args":{"configFilePath":"/...","rootDir":"/..."}},
{"pid":1,"tid":1,"ph":"B","cat":"parse","ts":...,"name":"createSourceFile","args":{"path":"/..."}},
{"pid":1,"tid":1,"ph":"E","cat":"parse","ts":...,"name":"createSourceFile","args":{"path":"/..."}},
{"pid":1,"tid":1,"ph":"X","cat":"program","ts":...,"name":"resolveModuleNamesWorker","dur":...,"args":{"containingFileName":"/..."}},
...
output

cat trace/types.json
<<output
[{"id":1,"intrinsicName":"any","recursionId":0,"flags":["..."]},
{"id":2,"intrinsicName":"any","recursionId":1,"flags":["..."]},
{"id":3,"intrinsicName":"any","recursionId":2,"flags":["..."]},
{"id":4,"intrinsicName":"error","recursionId":3,"flags":["..."]},
{"id":5,"intrinsicName":"unresolved","recursionId":4,"flags":["..."]},
{"id":6,"intrinsicName":"any","recursionId":5,"flags":["..."]},
{"id":7,"intrinsicName":"intrinsic","recursionId":6,"flags":["..."]},
{"id":8,"intrinsicName":"unknown","recursionId":7,"flags":["..."]},
{"id":9,"intrinsicName":"unknown","recursionId":8,"flags":["..."]},
{"id":10,"intrinsicName":"undefined","recursionId":9,"flags":["..."]},
{"id":11,"intrinsicName":"undefined","recursionId":10,"flags":["..."]},
{"id":12,"intrinsicName":"null","recursionId":11,"flags":["..."]},
{"id":13,"intrinsicName":"string","recursionId":12,"flags":["..."]},
...
output

TypeScript 4.7

  • ECMAScript Module Support in Node.js: When using ES Modules instead of CommonJS, TypeScript now supports specifying the default. Specify it in the tsconfig.json.
...
"compilerOptions": [
...
"module": "es2020"
]
...
  • type in package.json: The field type in package.json can be set to "module", which is needed to use node.js with ES Modules. In most cases, this is enough for TypeScript and the compiler option above is not needed.
...
"type": "module"
...
  • Instantiation Expressions: Instantiation expressions allow the specifying of type parameters when referencing a value. This allows the narrowing of generic types without creating wrappers.
class List<T> {
private list: T[] = [];

get(key: number): T {
return this.list[key];
}

push(value: T): void {
this.list.push(value);
}
}

function makeList<T>(items: T[]): List<T> {
const list = new List<T>();
items.forEach(item => list.push(item));
return list;
}

// Let's say we want to have a function that creates a list but only allows certain values.
// PREVIOUSLY:
// We need to manually define a wrapper function and pass the argument.
function makeStringList(text: string[]) {
return makeList(text);
}

// NEW:
// Using instantiation expressions, this is much easier.
const makeNumberList = makeList<number>;
  • extends Constraints on infer Type Variables: When inferring type variables in conditional types, they can now directly be narrowed/constrained by using extends.
// Let's say we want to type a type that only gets the first element of an array if it's a string.
// We can use conditional types for this.

// PREVIOUSLY:
type FirstIfStringOld<T> =
T extends [infer S, ...unknown[]]
? S extends string ? S : never
: never;

// But this needs two nested conditional types. We can also do it in one.
type FirstIfString<T> =
T extends [string, ...unknown[]]
// Grab the first type out of `T`
? T[0]
: never;

// This is still suboptimal because we need to index the array for the correct type.

// NEW:
// Using extends Constraints on infer Type Variables, this can be declared a lot easier.
type FirstIfStringNew<T> =
T extends [infer S extends string, ...unknown[]]
? S
: never;
// Note that the typing worked the same before, this is just a cleaner syntax.

type A = FirstIfStringNew<[string, number, number]>; // typed as string
type B = FirstIfStringNew<["hello", number, number]>; // typed as "hello"
type C = FirstIfStringNew<["hello" | "world", boolean]>; // typed as "hello" | "world"
type D = FirstIfStringNew<[boolean, number, string]>; // typed as never
  • Optional Variance Annotations for Type Parameters: Generics can have different behaviors when checking if they “match”, for example, the allowing of inheritance is reversed for getters and setters. This can now be optionally specified for clarity.
// Let's say we have an interface / a class that extends another one.
interface Animal {
animalStuff: any;
}

interface Dog extends Animal {
dogStuff: any;
}

// And we have some generic "getter" and "setter".
type Getter<T> = () => T;

type Setter<T> = (value: T) => void;

// If we want to find out if Getter<T1> matches Getter<T2> or Setter<T1> matches Setter<T2>, this depends on the covariance.
function useAnimalGetter(getter: Getter<Animal>) {
getter();
}

// Now we can pass a Getter into the function.
useAnimalGetter((() => ({ animalStuff: 0 }) as Animal));
// This obviously works.

// But what if we want to use a Getter which returns a Dog instead?
useAnimalGetter((() => ({ animalStuff: 0, dogStuff: 0 }) as Dog));
// This works as well because a Dog is also an Animal.

function useDogGetter(getter: Getter<Dog>) {
getter();
}

// If we try the same for the useDogGetter function we will not get the same behavior.
useDogGetter((() => ({ animalStuff: 0 }) as Animal)); // Type error: Property 'dogStuff' is missing in type 'Animal' but required in type 'Dog'.
// This does not work, because a Dog is expected, not just an Animal.

useDogGetter((() => ({ animalStuff: 0, dogStuff: 0 }) as Dog));
// This, however, works.

// Intuitively we would maybe expect the Setters to behave the same, but they don't.
function setAnimalSetter(setter: Setter<Animal>, value: Animal) {
setter(value);
}

// If we pass a Setter of the same type it still works.
setAnimalSetter((value: Animal) => {}, { animalStuff: 0 });

function setDogSetter(setter: Setter<Dog>, value: Dog) {
setter(value);
}

// Same here.
setDogSetter((value: Dog) => {}, { animalStuff: 0, dogStuff: 0 });

// But if we pass a Dog Setter into the setAnimalSetter function, the behavior is reversed from the Getters.
setAnimalSetter((value: Dog) => {}, { animalStuff: 0, dogStuff: 0 }); // Type error: Argument of type '(value: Dog) => void' is not assignable to parameter of type 'Setter<Animal>'.

// This time it works the other way around.
setDogSetter((value: Animal) => {}, { animalStuff: 0, dogStuff: 0 });

// NEW:
// To signal this to TypeScript (not needed but helpful for readability), use the new Optional Variance Annotations for Type Parameters.
type GetterNew<out T> = () => T;
type SetterNew<in T> = (value: T) => void;
  • Resolution Customization with moduleSuffixes: When using environments that have custom file suffixes (for example .ios for native app builds), these can now be specified for TypeScript to correctly resolve imports. Specify them in the tsconfig.json.
...
"compilerOptions": [
...
"moduleSuffixes": [".ios", ".native", ""]
]
...
import * as foo from './foo';
// This first checks ./foo.ios.ts, ./foo.native.ts, and finally ./foo.ts.
  • Go to Source Definition in editors: In editors, the new “go to source definition” menu option is available. It is similar to “go to definition”, but prefers .ts and .js files over type definitions (.d.ts).

TypeScript 4.9

  • The satisfies Operator: The satisfies operator allows checking the compatibility with types without actually assigning that type. This allows for keeping more accurate inferred types while still keeping compatibility.
// PREVIOUSLY:
// Let's say we have an object/map/dictionary which stores various items and their colors.
const obj = {
fireTruck: [255, 0, 0],
bush: '#00ff00',
ocean: [0, 0, 255]
} // typed as { fireTruck: number[]; bush: string; ocean: number[]; }

// This implicitly types the properties so we can operate on the arrays and the string.
const rgb1 = obj.fireTruck[0]; // typed as number
const hex = obj.bush; // typed as string

// Let's say we only want to allow certain objects.
// We could use a Record type.
const oldObj: Record<string, [number, number, number] | string> = {
fireTruck: [255, 0, 0],
bush: '#00ff00',
ocean: [0, 0, 255]
} // typed as Record<string, [number, number, number] | string>
// But now we lose the typings of the properties.
const oldRgb1 = oldObj.fireTruck[0]; // typed as string | number
const oldHex = oldObj.bush; // typed as string | number

// NEW:
// With the satisfies keyword we can check compatibility with a type without actually assigning it.
const newObj = {
fireTruck: [255, 0, 0],
bush: '#00ff00',
ocean: [0, 0, 255]
} satisfies Record<string, [number, number, number] | string> // typed as { fireTruck: [number, number, number]; bush: string; ocean: [number, number, number]; }
// And we still have the typings of the properties, the array even got more accurate by becoming a tuple.
const newRgb1 = newObj.fireTruck[0]; // typed as number
const newRgb4 = newObj.fireTruck[3]; // Type error: Tuple type '[number, number, number]' of length '3' has no element at index '3'.
const newHex = newObj.bush; // typed as string
  • “Remove Unused Imports” and “Sort Imports” Commands for Editors: In editors, the new commands (and auto-fixes) “Remove Unused Imports” and “Sort Imports” make managing imports easier.

TypeScript 5.0

  • ES Decorators support: TypeScript now supports ES Decorators (an upcoming ES feature), which are different from the already existing decorators. Previously, TypeScript had experimental support for Decorators which Frameworks like Angular use, they were opt-in with the flag --experimentalDecorators (or in tsconfig.json) and behaved slightly differently, though they will continue to exist for the foreseeable future. Keep in mind that it's either-or, so they can not be combined. The new ES Decorators can not emit metadata in the same way the old ones do and are also not yet supported on parameters.
// NOTICE: As of May 2023 this is an upcoming ES feature, therefore this code does not actually work yet.

// While debugging we might want to log whenever we enter or exit a method.
// Doing this manually can be quite tedious, is there a way to automate this?
// Yes, using decorators we can replace the execution context.

// We first define a function that will act as the decorator, it takes the original method and a context object as parameters and returns the new function to execute.
function logMethod<This, Args extends any[], Return>(originalMethod: (this: This, ...args: Args) => Return, context: ClassMethodDecoratorContext) {
const methodName = String(context.name);

function replacementFunction(this: This, ...args: Args) {
console.log(`LOG: Entering method '${methodName}'.`)
const result = originalMethod.call(this, ...args);
console.log(`LOG: Exiting method '${methodName}'.`)
return result;
}

return replacementFunction;
}

// Then we define a class to use it, decorators only work on classes and their members.
// We "decorate" the method with an `@` and our function name.
class Test {
@logMethod
doSomething() {
return 'Doing';
}
}

const testObj = new Test();

// When we execute the method, our replacement method will be called.
console.log(testObj.doSomething()); // "LOG: Entering method 'doSomething'.", "Doing", "LOG: Exiting method 'doSomething'."

// We can also define a decorator factory (a function that returns a decorator function) to pass additional arguments, like a custom message here.
function logMethodCustom(customMessage: string) {
return <This, Args extends any[], Return>(originalMethod: (this: This, ...args: Args) => Return, context: ClassMethodDecoratorContext) => {
const methodName = String(context.name);

function replacementFunction(this: This, ...args: Args) {
console.log(`${customMessage}: Entering method '${methodName}'.`)
const result = originalMethod.call(this, ...args);
console.log(`${customMessage}: Exiting method '${methodName}'.`)
return result;
}

return replacementFunction;
}
}

// We "decorate" the method with an `@` and our function name, but this time followed with a function call like `()` containing the parameters.
class TestCustom {
@logMethodCustom('TEST LOG')
doSomething() {
return 'Doing';
}
}

const testCustomObj = new Test();

// When we execute the method, everything works as expected.
console.log(testCustomObj.doSomething()); // "TEST LOG: Entering method 'doSomething'.", "Doing", "TEST LOG: Exiting method 'doSomething'."

// If we don't return a replacement method in the decorator function, the original method will be called, but we can still set up things related to it,
// for example bind the `this` before the `constructor` phase so it will run the same if passed as a callback outside the class.
// To execute code before the constructor phase, we can use the `addInitializer` method of the context object.
function bindThis(_: unknown, 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: any) {
const methodName = context.name;
if (typeof methodName === 'string') {
this[methodName] = this[methodName].bind(this);
}
});
}

// We define it once without binding to test.
class TestUnbound {
private returnVal = 'Doing';

doSomething() {
return this.returnVal;
}
}

const testUnboundObj = new TestUnbound();

// We "decorate" the method again.
class TestBound {
private returnVal = 'Doing';

@bindThis
doSomething() {
return this.returnVal;
}
}

const testBoundObj = new TestBound();

// When we store the method outside the class context and execute it, it will not be able to get the property value.
const unboundFunc = testUnboundObj.doSomething;
console.log(unboundFunc()); // Error: "Cannot read properties of undefined (reading 'returnVal')"

// But when it's bound, it works as expected.
const boundFunc = testBoundObj.doSomething;
console.log(boundFunc()); // "Doing"
  • const Type Parameters: Specifying const in front of a type parameter attempts to make type parameter inference act like as const. However, if they are typed as mutable values they can not be inferred (because readonly types are not assignable to mutable types), therefore they should always inherit readonly types. This does not restrict the parameters to be accepted either, they still need to be restricted inside the extends.
// PREVIOUSLY:
// Let's say we have a type that stores config values and a function to get a certain value from the config.
// The type is set to be readonly to be compatible with `as const`.
type ReadonlyConfig = { compilerFlags: readonly string[] };

// The function infers the type argument from the parameter to automatically get a correct return type.
function getCompilerFlags<T extends ReadonlyConfig>(arg: T): T['compilerFlags'] {
return arg.compilerFlags;
}

// If we use the function on a regular object, the return type is not defined exactly.
const compilerFlags = getCompilerFlags({ compilerFlags: ['firstFlag', 'anotherFlag', 'importantFlag']}); // typed as string[]

// If we want to assign the result to a typed variable, TS cannot check if it's a valid assignment.
const foundCompilerFlags: readonly `${string}Flag`[] = compilerFlags; // Type error: Type 'string[]' is not assignable to type readonly '`${string}Flag`[]'.

// Instead, we might "recommend" to the caller to use `as const` to get more precise typings.
const compilerFlagsAsConst = getCompilerFlags({ compilerFlags: ['firstFlag', 'anotherFlag', 'importantFlag']} as const); // typed as readonly ["firstFlag", "anotherFlag", "importantFlag"]

// If we try to assign the result to a typed variable again, TS now realizes that it's a valid assignment.
const foundCompilerAsConstFlags: readonly `${string}Flag`[] = compilerFlagsAsConst; // typed as readonly `${string}Flag`[]
// This works, but it depends on the caller.

// NEW:
// We can specify `const` in front of type arguments to prefer treating type inference like `as const` (but this is not guaranteed, more on this below).
function getCompilerFlagsConst<const T extends ReadonlyConfig>(arg: T): T['compilerFlags'] {
return arg.compilerFlags;
}

// If we use it now, we automatically get the correct return type.
const compilerFlagsConst = getCompilerFlagsConst({ compilerFlags: ['firstFlag', 'anotherFlag', 'importantFlag']}); // typed as readonly ["firstFlag", "anotherFlag", "importantFlag"]

// If we try to assign the result to a typed variable again, TS also realizes that it's a valid assignment.
const foundCompilerConstFlags: readonly `${string}Flag`[] = compilerFlagsConst; // typed as readonly `${string}Flag`[]
// This works and is not dependent on the caller.

// But:
// If we don't define the config type as readonly, the function can also not infer the type correctly.
type Config = { compilerFlags: string[] };
function getCompilerFlagsConstIncorrect<const T extends Config>(arg: T): T['compilerFlags'] {
return arg.compilerFlags;
}

// If we use it again, we don't get the exact return type.
const compilerFlagsConstIncorrect = getCompilerFlagsConstIncorrect({ compilerFlags: ['firstFlag', 'anotherFlag', 'importantFlag']}); // typed as string[]
// This is because `string[]` is mutable, which means that `readonly [...]` is not assignable to it.
// Or in simple words: `string[]` assumes the array can be modified, so a non-modifiable array (which is what `const` tries to infer) it not assignable to it.

// If we try to assign the result to a typed variable again, TS cannot check if it's a valid assignment again.
const foundCompilerConstIncorrectFlags: readonly `${string}Flag`[] = compilerFlagsConstIncorrect; // Type error: Type 'string[]' is not assignable to type readonly '`${string}Flag`[]'.

// Also:
// This is only for inference and does not restrict the parameters to be accepted.
// So a variable specifically typed as `string[]` can still be used to call the function.
const compilerFlagsConstInaccurate = getCompilerFlagsConstIncorrect({ compilerFlags: ['firstFlag', 'anotherFlag', 'importantFlag'] as string[]}); // typed as string[]
  • Supporting Multiple Configuration Files in extends: Instead of having to create complex inheritance structures (which forced all of the "tsconfig"s to always extend from the same config or copy-paste all the settings), multiple files can now be extended in "tsconfig" files by specifying "extends": ["./tsconfig1.json", "./tsconfig2.json"]. The latter files are preferred for overriding behavior.
...
// Here "tsconfig1.json" overrides "@tsconfig/strictest/tsconfig.json", "tsconfig2.json" overrides "tsconfig1.json" (and "@tsconfig/strictest/tsconfig.json") and this file still overrides them all.
"extends": ["@tsconfig/strictest/tsconfig.json", "./tsconfig1.json", "./tsconfig2.json"],
...
  • All enums are union enums: Dynamically assigned enum values no longer default to the old enum strategy (No types for enum keys, can only be used as values but not as types), but can instead now be used as types as well.
// Enums create a different type for every value.
enum Color {
Red, Green, Blue, Orange, Yellow, Violet
}

// This allows them to be narrowed and used just like any other types.
type PrimaryColor = Color.Red | Color.Green | Color.Blue;

// But:
// If the values are assigned dynamically, there are no fixed values.
// That's why previously they would fall back to the old enum behavior.
// In the old enum behavior, their members are only values but not types.
enum ColorRandom {
Red = Math.random(),
Green = Math.random(),
Blue = Math.random(),
Orange = Math.random(),
Yellow = Math.random(),
Violet = Math.random()
}

// So they used to also not be able to be narrowed.
type PrimaryColorRandom = ColorRandom.Red | ColorRandom.Green | ColorRandom.Blue; // Previous type error: Enum type 'ColorRandom' has members with initializers that are not literals.
// But now they also have their own types so this works.
  • --moduleResolution bundler: The module resolution strategy "node16" in TS 4.7 allowed for better modeling of ES Modules but had some unnecessary restrictions (File extensions had to be specified, ...). If you are using a bundler, the new strategy attempts to keep the newer features while removing some of the restrictions. It can be used by specifying the compiler flag --moduleResolution bundle (or in tsconfig.json). Only use when using a bundler.
...
"compilerOptions": [
...
"moduleResolution": "bundler"
]
...
import * as foo from './foo';
// This is now allowed again even though no file extension is specified.
  • Resolution Customization Flags: Set rules for “hybrid” module resolution. Rules include: allowImportingTsExtensions, resolvePackageJsonExports (consider package.json exports for imports), resolvePackageJsonImports (Consult local package.json for paths starting with #), allowArbitraryExtensions and customConditions (only for node16 and bundler, used for conditional exports and imports in package.json). They can all be specified as compiler flags by prepending -- (or in tsconfig.json).
...
"compilerOptions": [
...
"allowImportingTsExtensions": false,
"resolvePackageJsonExports": true,
"resolvePackageJsonImports": true,
"allowArbitraryExtensions": false,
"customConditions": ["my-condition"]
// This will try to resolve custom conditions for `exports` and `imports` in `package.json`.
// TS will then try to match files according to the custom condition (in this case `foo.mjs`).
]
...
{
...
"exports": {
".": {
"my-condition": "./foo.mjs",
"node": "./bar.mjs",
"import": "./baz.mjs",
"require": "./biz.mjs"
}
}
}
  • --verbatimModuleSyntax: Stop typescript from automatically stripping away imports (import elision) and exports because only types are used. This can be useful when import side effects are used or TS doesn't realize the import is used somewhere. import type and export type statements will still be stripped away from the final compilation, it is generally recommended to specify whether an import is a type or not. It can be used by specifying the compiler flag --verbatimModuleSyntax (or in tsconfig.json).
// This import will be stripped away from the final compilation unless `verbatimModuleSyntax` is set.
import { Car } from './car';
// This import will be stripped away anyway.
import type { Car } from './car';
// This import will never be stripped away.
import { logCar } from './car';

export function drive(car: Car) {
logCar(car);
// ...
}
  • Support for export type *: Easily re-export default types as submodules.
// Export the default types as a submodule
export type * as am from 'another-module';
// or re-export them as the default export again
export type * from 'another-module';
import type { am } from 'module';
// or
import type { } from 'module';
  • Passing Emit-Specific Flags Under --build: Flags to specify behavior for builds (when using --build). They include --declaration, --emitDeclarationOnly, --declarationMap, --sourceMap and --inlineSourceMap. They can all be specified as compiler flags now, instead of just in tsconfig.json.
# This will emit the type declarations even when they are disabled in `tsconfig.json`.
tsc --build --declaration
  • Case-Insensitive Import Sorting in Editors: Change the case sensitivity behavior for imports sorting in editors. This can be enabled and configured under typescript.unstable in the JSON settings in VSCode.
  • Exhaustive switch/case Completions: Editors can now autocomplete case statements for literal types.