TypeScript 4.5 has been released as a beta version of 10.1. This article covers some of the notable new features and changes in TypeScript, such as the.mts /.cts extension, new type import syntax, and new built-in utility types. You can also read devblog for more.

Node ESM supports ECMAScript Module Support in Node.js

Node12 ESM support is now available on TS4.5. This is the most important new feature in TS4.5.

  • Module: node12 and nodenext have been added

  • package.json type

    NodeJS supports setting type to module or commonjs in package.json to explicitly specify how the JavaScript file should be parsed. There are still some significant differences between ESM and CJS, such as relative path imports that require a path with an extension, in the form of import “./foo.js”. TS4.5 now provides the same workflow for this, meaning that the Type field in package.json is now also read by TS to decide whether to parse it as ESM.

  • New file extensions:.mts and.cts

    In addition to using the type field to control module parsing, you can also explicitly declare files using the two new TS4.5 extensions.mts and.cts. Just like in NodeJS,.mjs will always be treated as ESM and.cjs as CJS. The two new extensions will compile to.d.ts +.mjs or.d.c. ts +.cjs.

  • Exports and imports in package.json:

    In a simple case, we would just use the main field to define the entry to the application, but for finer control over the files exposed to the user, we would use exports and imports. I first saw this use in Astro, which moved the CLI-related code out. Makes it impossible for the user to customize the Programmatic interface (although I don’t know why, because it’s unstable?).

    You can find more information about this section in the NodeJS documentation, but we’ll cover it briefly here. When you define exports like this:

    {
      "name": "pkg"."exports": {
        ".": "./main.mjs"."./foo": "./foo.js"."./dir": "./some-dir"}}Copy the code

    The user can refer to the contents of PKG /main.mjs via PKG /foo, and to the contents of PKG /foo.js via PKG /foo. Refer to file.js under PKG /dir through PKG /dir/file.js, and you cannot apply the contents of PKG /cli.js through PKG /cli, even if it is referenced in main.mjs.

    And, thanks to the self-referencing feature, you can also use your bag name to reference yourself in the internal documents of the bag.

    You can find out what problems this proposal was designed to solve and more on this at Proposal-PKG-exports.

    You can also specify ESM and CJS entries in this way:

    {
      "name": "pkg"."exports": {
        ".": {
          "import": "./esm/index.mjs"."require": "./cjs/index.cjs",}}}Copy the code

    Thus providing different entrances for import(PKG) and require(PKG).

    Back to TS’s original logic, it checks main and its associated type files (e.g../lib/main.js corresponds to./lib/main.d.ts) or gets the declaration file address (if any) from types. Similarly, now if you use import, it looks for the type declaration file at the address of import, whereas if it’s require, you can still add separate types fields:

    {
      "name": "pkg"."exports": {
        ".": {
          "import": "./esm/index.mjs"."require": "./cjs/index.cjs"."types": "./types/index.d.ts"}},// Compatible with previous versions
      "types": "./types/index.d.ts"
    }
    Copy the code

Loading lib Supporting from node_modules is supportedlib from node_modules

We know that compileroptions. lib in tsConfig contains syntax or apis that need to be used at compile time, usually language and context-specific API declarations such as DOM, ESNext, WebWorker, etc. For example, to use promises, You need ES2015, and to use replaceAll you need ESNext.

There are some issues with this approach that make fine-grained customization difficult, such as I only need part of the DOM and part of ESNext. Or Breaking Change that may exist in the built-in Lib declaration when updating the TS version. Another way you might think of managing global types is DefinitelyTyped, an NPM package like @types/ Node. Thus TS4.5 also supports explicit installation dependencies in this way, such as @typescript/lib-dom representing the original DOM.

Node_modules /@typescript/lib-dom when you include DOM in your dependencies, TS checks node_modules/@typescript/lib-dom to see if the DOM package exists.

{
 "dependencies": {
    "@typescript/lib-dom": "npm:@types/web"}}Copy the code

NPM: This protocol is actually provided by NPM, as well as file: and workspace (Yarn2 workspace, PNPM workspace). You actually install @types/web as well.

For changes in parsing logic, see Compiler /program.ts.

The new built-in tool type is TheAwaited Type and Promise Improvements

TS 4.5 introduces a new tool type, called “COMMIT”, which represents a Promise resolve value type. Community tool libraries already have similar tool types, such as PromiseValue in Type-fest:

export type PromiseValue<PromiseType> = PromiseType extends PromiseLike<infer Value> ? PromiseValue<Value> : PromiseType;
Copy the code

What it does is essentially perform recursive unboxing, extracting the types of Promise values. However, unlike community implementations, official lifts are also used as the underlying implementation for related methods such as promise.all Promise.race, such as the promise.all method prior to TS4.5. The type definition is as follows:

interface PromiseConstructor {
		all<T>(values: readonly (T | PromiseLike<T>)[]): Promise<T[]>;
		/ /... Omit countless overloads
    all<T1, T2, T3, T4, T5, T6, T7, T8, T9, T10>(values: readonly [T1 | PromiseLike<T1>, T2 | PromiseLike<T2>, T3 | PromiseLike<T3>, T4 | PromiseLike<T4>, T5 | PromiseLike<T5>, T6 | PromiseLike<T6>, T7 | PromiseLike<T7>, T8 | PromiseLike<T8>, T9 | PromiseLike<T9>, T10 | PromiseLike<T10>]): Promise<[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10]>;
}
Copy the code

Now it looks like this:

interface PromiseConstructor {
  all<T extends readonly unknown[] | []>(values: T): PromiseThe < {-readonly [P in keyof T]: Awaited<T[P]> }>;
}
Copy the code

Template String Types as Discriminants

For interfaces that have the same field, we usually use type guards to explicitly specify the bundle type, as in:

export interface Success {
  type: string;
  body: string;
}

export interface Error {
  type: string;
  message: string;
}

export function isSuccess(r: Success | Error) :r is Success {
  return "body" in r
}
Copy the code

Using the IS keyword can help the compiler further bind generics to their corresponding types. See TypeScript: Type Programming or TypeScript: Type Programming (2021 rework) for more on type guards, is keywords, and template string types.

TS now provides support for template string types as well:

export interface Success {
  type: `The ${string}Success`;
  body: string;
}

export interface Error {
  type: `The ${string}Error`;
  message: string;
}

export function isSuccess(r: Success | Error) :r is Success {
  return r.type === "HttpSuccess"
}
Copy the code

New available Module configuration--module es2022

TS 4.5 supports the new compilerOptions.module configuration: Es2022, whose main feature is top-level await, is also available in ESNext, but ES2022 is the first ES release where this proposal is officially included. Nodenext also includes support for this feature.

Tail-Recursion Elimination on Conditional Types

When we use TS aliases, we often encounter situations where we need to loop through the type aliases themselves. The TS compiler detects and warns about possible infinite nesting, such as the following:

type InfiniteBox<T> = { item: InfiniteBox<T> }

type Unpack<T> = T extends { item: infer U } ? Unpack<U> : T;

// Type instantiation is too deep and may be infinite.
// error: Type instantiation is excessively deep and possibly infinite.
type Test = Unpack<InfiniteBox<number>>
Copy the code

If you’ve done some type gymnastics based on template string types, you’ve probably come across this kind of infer and conditional type:

type TrimLeft<T extends string> =
    T extends ` ${infer Rest}` ? TrimLeft<Rest> : T;

// Test = "hello" | "world"
type Test = TrimLeft<" hello" | " world">;
Copy the code

For template strings, TS provides specialized tool types Uppercase Capitalize and Uncapitalize

This looks good now, but if you put a lot of space at the beginning of the string, you might get an error. In fact, this type of string extraction is quite common, especially for URL parsing (see Farrow’s core feature).

If you go back to the implementation of TrimLeft itself, you’ll see that it’s actually tail-recursive in that it returns a value immediately on each recursive call, with no additional operations on the return value. This way, the TS compiler does not need to create the intermediate variable separately each time, so it no longer fires the warning.

The counterexample is to use additional judgment results of conditional types, such as

type GetChars<S> =
    S extends `${infer Char}${infer Rest}` ? Char | GetChars<Rest> : never;
Copy the code

Here, Char and Rest are combined into a joint type, which causes extra work every time, and is therefore still warned when complexity reaches a certain level.

This is one of the key features introduced in TS4.5. If the branch of a conditional type simply returns another type (itself, other tool types, generics, infer values, etc.), TS can reduce a lot of unnecessary intermediate work and thus be “less cumbersome” than before.

  • Recursive processing condition type, because it’s tail recursive so it’s ok
  • Not the same as a circular reference itself
  • Intelligent organization detects that the branch of a condition type is still a condition type

Disabling Import Elision

In TypeScript, import members that are not used are automatically removed, for example

import { Foo, Bar } from "some-mod"

Foo()
Copy the code

The Bar will be removed at compile time, but what if there are some cases where TS’s built-in checking strategy doesn’t work? For example, eval:

import { Foo, Bar } from "some-mod"

eval("console.log(Foo())")
Copy the code

In such cases we do not want imported members to be removed, and TS4.5 introduced a new compile-time option, preserveValueImports, to avoid any imports being removed.

This feature also has special implications for frameworks such as Vue, Svelte, and Astro that use custom files (.vue/.svelte/.astro), where compilation of templates is handled by themselves and compilation of script parts is handled by TS. This makes the template part’s use of imports invisible to the TS compiler and requires additional work.

In previous versions TS also introduced the –importsNotUsedAsValues option to control the entire import statement, with values such as:

  • remove(Default), only import statements that introduce only types are removed
  • preserveAll import statements whose imported values or types are not being used are preserved
  • error, similar to thepreserve, but throws an error if the import is type-only

When used with –preserveValueImports and –isolatedModules, you must ensure that type imports are imported type-only, such as import Type and Type decorator. Let’s start with a quick introduction –isolatedModules:

If you’ve had any experience with –isolatedModules, you know that every file must be a module (with at least one export {}), because for tools like TS-Loader Babel esbuild, They are typically processed as a single file (as is TypeScript’s transpileModule API), unlike TSC where the preprocessor collects source files, generates compilation contexts, and so on.

So there is a limitation when enabling –isolatedModules:

  • Every file must be a module, i.eimportOr have aexport
  • Type imports cannot be exported again (re-export), because the type code is actually removed at compile time, so that other compilers don’t sense the problem and the code runs in an error.
  • Enumeration of constants (const enumsUnlike ordinary enumerations, constant enumerations are directly inline and erased at compile time, i.e. used in codeSomeEnum.FooIs replaced directly with the value of the enumeration, so that a single-file compilation cannot get the value of the constant enumeration unless it is defined in the same file.

New type import syntaxtype Modifiers on Import Names

Prior to TS4.5, we could identify an import statement with named import members of type.

import type { CompilerOptions } from "typescript"
import { transpileModule } from "typescript"
Copy the code

It’s not pretty. You need to split it up into two import statements, and if you have a compulsion, you might want to separate the import statements of the file, like

// Type import
import type { CompilerOptions } from "typescript"
import type { OpenDirOptions } from "fs-extra"
import type { Options } from "chalk"

// Actual import
import { transpileModule } from "typescript";
import { opendirSync } from "fs-extra"
import chalk from "chalk"
Copy the code

But now, you can directly add the Type modifier to named type imports, identifying the actual import and type import in a single line of imports.

import { transpileModule, type CompilerOptions } from "typescript";
Copy the code

Import Assertions Import Assertions

This new feature comes from proposal-import-assertions and is currently in Stage 3. It introduces new syntax import json from “./foo.json” assert {type: “json”}; To explicitly identify the type of the imported module. This proposal actually has a lot to offer, such as configuring HTML and CSS Modules to implement true/official componentalization. The original proposal was for importing JSON files, but now it has a separate proposal: proposal-Json-modules. This proposal could also be used for:

  • Reimport statements, as inexport { val } from './foo.js' assert { type: "javascript" };
  • Dynamic import statements, such asawait import("foo.json", { assert: { type: "json" } })In TypeScript 4.5, it is specifically addedImportCallOptionsTo dynamically import the type definition for the second parameter.

In addition, TC39 proposals will inevitably continue to integrate into TypeScript as new features become available, and you can read about more TC39 proposals in progress in this post about TC39 Proposals in Progress.

Better Unresolved Types prompt Better Editor Support for Unresolved Types

The new /*unresolved*/ feature is mainly used to declare new /*unresolved*/ features for unresolved types:

Until then, unresolved type declarations are marked only as any.

You can see the Iteration Plan for version 4.5 in TypeScript 4.5 Iteration Plan.