44

image.png

background

TypeScript 3.8 brings a new feature: only imports/exports declarations.

In last article , we used this feature to solve: introducing the issue of being given file type.

In fact, this feature is not complicated, but we need to understand the mechanism behind it and understand how Babel and TypeScript work together.

The main content of this article:

  • What is "only import/export declaration"
  • Babel and TypeScript work together

text

First of all, let me introduce this feature.

What is "only import/export declaration"

To allow us to import types, TypeScript reuses JavaScript import syntax.

For example, in the following example, we ensure that the JavaScript value doThing and the TypeScript type Options are imported together:

// ./foo.ts
interface Options {
  // ...
}

export function doThing(options: Options) {
  // ...
}

// ./bar.ts
import { doThing, Options } from './foo.js';

function doThingBetter(options: Options) {
  // do something twice as good
  doThing(options);
  doThing(options);
}

This is very convenient, because in most cases, we don't have to worry about what is imported-just what we want to import.

Unfortunately, this only works because of a feature called "import omission".

When TypeScript outputs a JavaScript file, TypeScript will recognize that Options is only used as a type, and it will delete Options.

// ./foo.js
export function doThing(options: Options) {
  // ...
}

// ./bar.js
import { doThing } from './foo.js';

function doThingBetter(options: Options) {
  // do something twice as good
  doThing(options);
  doThing(options);
}

Under normal circumstances, this behavior is better. But it can cause some other problems.

First of all, in some scenarios, TypeScript will confuse whether the export is a type or a value.

For example, in the following example, is MyThing a value or a type?

import { MyThing } from './some-module.js';

export { MyThing };

If we look at this document alone, we have no way of knowing the answer.

If Mything is just a type, the code compiled by the transpileModule API used by Babel and TypeScript will not work correctly, and the isolatedModules compilation option of TypeScript will prompt us that this way of writing will throw an error.

The crux of the problem is that there is no way to recognize that it is just a type and whether it should be deleted, so "import omission" is not good enough.

At the same time, there is another problem. TypeScript import omission will remove import statements that only contain type declarations.

For modules with side-effects, this resulted in significantly different behavior. As a result, users will have to add an additional statement statement to ensure that there are side effects.

// This statement will get erased because of import elision.
import { SomeTypeFoo, SomeOtherTypeBar } from './module-with-side-effects';

// This statement always sticks around.
import './module-with-side-effects';

A specific example we have seen is in Angularjs (1.x), services need to be registered globally (it is a side effect), but imported services are only used in type declarations.

// ./service.ts
export class Service {
  // ...
}
register('globalServiceId', Service);

// ./consumer.ts
import { Service } from './service.js';

inject('globalServiceId', function(service: Service) {
  // do stuff with Service
});

As a result, the code in ./service.js will not be executed, causing it to be interrupted during runtime.

In TypeScript 3.8 version, we added a only import/export declaration syntax as a solution.

import type { SomeThing } from "./some-module.js";

export type { SomeThing };

import type only imports declaration statements that are used for type annotations or declarations. It will always be completely deleted, so no code will be left at runtime.

Similar to this, export type only provides an export for the type, and it will also be deleted in the TypeScript output file.

It is worth noting that classes have values at runtime and types at design time. Its use depends on the context.

When importing a class using import type, you cannot do operations similar to inheriting from it.

import type { Component } from "react";

interface ButtonProps {
    // ...
}

class Button extends Component<ButtonProps> {
    //               ~~~~~~~~~
    // error! 'Component' only refers to a type, but is being used as a value here.

    // ...
}

If you have used Flow before, their syntax is similar.

One difference is that we have added a new restriction to avoid potentially confusing code.

// Is only 'Foo' a type? Or every declaration in the import?
// We just give an error because it's not clear.

import type Foo, { Bar, Baz } from "some-module";
//     ~~~~~~~~~~~~~~~~~~~~~~
// error! A type-only import can specify a default import or named bindings, but not both.

Associated with import type, we provide a new compilation option: importsNotUsedAsValues , through which you can control how unused import statements will be processed. Its name is tentative, but it provides three different Options.

  • remove , this is the current behavior-discard these import statements. This is still the default behavior, no breaking changes
  • preserve , it will retain all the statements, even if they are never used. It can retain side effects.
  • error , it will keep all import (same as preserve option) statements, but will throw an error when importing a value only for type. This is useful if you want to ensure that no values are accidentally imported, but for side effects, you still need to add additional import syntax.

For more information on this feature, refer to the PR .

How Babel and TypeScript work together

TypeScript does two things

  1. Add static type checking to the JavaScript code.
  2. Convert TS + JS code into various JS versions.

Babel also does the second thing.

Babel's method (especially when transform-typescript plug-in) is: delete the type first, and then perform the conversion.

In this way, you can use all the advantages of Babel while still being able to provide ts files.

Look at an example:

Before babel compilation:

// example.ts
import { Color } from "./types";
const changeColor = (color: Color) => {
  window.color = color;
};

After babel is compiled:

// example.js
const changeColor = (color) => {
  window.color = color;
};

Here, babel cannot tell example.ts that Color is actually a type.

Therefore, Babel was also forced to mistakenly keep this statement in the converted code.

Why is this happening?

Babel explicitly processes one file at a time during the translation process.

Probably because the babel team does not want to build in the same type resolution process like TypeScript, just to delete these types.

isolatedModules

What is isolatedModules

isolatedModules is a TypeScript compiler option designed to act as a protective measure.

isolatedModules type checking, when it detects that 0607854fb9d126 is turned on, it will report a type error.

If the error is not resolved, it will affect the compilation tool (babel) that processes files independently.

From TypeScript docs:

Perform additional checks to ensure that separate compilation (such as with transpileModule or @babel/plugin-transform-typescript) would be safe.

From Babel docs:

--isolatedModules This is the default Babel behavior, and it can't be turned off because Babel doesn't support cross-file analysis.

In other words, each ts file must be able to be compiled independently.

The isolatedModules logo prevents us from introducing ambiguous imports.

Let's look at two specific examples to see a few examples to understand the importance of the isolatedModules tag.

1. Mixed import, mixed export

Here, we take types.ts file, and then re-export them from it.

When the isolatedModules is opened, this code will not pass the type check.

// types.ts
export type Playlist = {
  id: string;
  name: string;
  trackIds: string[];
};

export type Track = {
  id: string;
  name: string;
  artist: string;
  duration: number;
};

// lib-ambiguous-re-export.ts
export { Playlist, Track } from "./types";
export { CreatePlaylistRequestParams, createPlaylist } from "./api";

After Babel conversion:

// dist/types.js
--empty--

// dist/lib-ambiguous-re-export.js
export { Playlist, Track } from "./types";
export { CreatePlaylistRequestParams, createPlaylist } from "./api";

Error:

image.png

Some understanding:

  • Babel removed everything from our types module, it only contains types.
  • Babel did not perform any conversion of our lib module. Playlist and Track should be removed by Babel. From Node's point of view, when Node is doing module analysis, you will find that the file imported in is 1607854fb9d270 empty, and an error is reported: the file does not exist.
  • As shown in the screenshot, the tsc type checking process immediately reports these obscure re-exports as errors.

2. Explicit type import, explicit type export

This time, we explicitly re-export the types in lib-import-export.ts.

When the isolatedModules is opened, this code will pass the tsc type check.

Before compilation:

// types.ts
export type Playlist = {
  id: string;
  name: string;
  trackIds: string[];
};

// lib-import-export.ts
import {
  Playlist as PlaylistType,
  Track as TrackType,
} from "./types";

import {
  CreatePlaylistRequestParams as CreatePlaylistRequestParamsType,
  createPlaylist
} from "./api";

export type Playlist = PlaylistType;
export type Track = TrackType;
export type CreatePlaylistRequestParams = CreatePlaylistRequestParamsType;
export { createPlaylist };

After compilation:

// dist/types.js
--empty-- TODO or does babel remove it all together?

// lib-import-export.js
import { createPlaylist } from "./api";
export { createPlaylist };

at this time:

  • Babel still outputs an empty types.js file. But it doesn't matter, because the lib-import-export.js we compiled doesn't reference it anymore.

TypeScript 3.8

As previously introduced, TypeScript 3.8 introduces a new syntax-"Import/Export Declaration Only".

This grammar adds certainty to the type resolution process when used.

Now, the compiler (whether it is tsc, babel or other) will be able to view a single file and cancel the import or export (if it is a TypeScript type).

import type ... from — Let the compiler know that the content you want to import is definitely a type.

export type ... from — Same as for export only.


// src/lib-type-re-export.ts
export type { Track, Playlist } from "./types";
export type { CreatePlaylistRequestParams } from "./api";
export { createPlaylist } from "./api";

// 会被编译为:

// dist/lib-type-re-export.js
export { createPlaylist } from "./api";

More references

  1. New part of the TS document: https://www.typescriptlang.org/docs/handbook/modules.html#importing-types
  2. Introduced TS PR for type import. There is a lot of great information in the PR description: https://github.com/microsoft/TypeScript/pull/35200
  3. TS 3.8 announcement: https://devblogs.microsoft.com/typescript/announcing-typescript-3-8-beta/#type-only-imports-exports
  4. Babel PR, enhanced the babel parser and transform-typescript plugin to take advantage of the new syntax. Released with Babel 7.9: https://github.com/babel/babel/pull/11171

皮小蛋
8k 声望12.8k 粉丝

积跬步,至千里。