Skip to content

Commit 3351fd0

Browse files
authored
Merge pull request #26 from total-typescript/matt/added-section-on-import-type-and-added-cjs-context-to-module-nodenext
Added section on import type and added CJS context to module NodeNext
2 parents 2b7e6a7 + c9aaf97 commit 3351fd0

File tree

1 file changed

+213
-2
lines changed

1 file changed

+213
-2
lines changed

book-content/chapters/14-configuring-typescript.md

Lines changed: 213 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,11 @@ Based on your answers to the above questions, here's how the complete `tsconfig.
7272
"strict": true,
7373
"noUncheckedIndexedAccess": true,
7474

75-
/* If transpiling with TypeScript: */
75+
/* If transpiling with tsc: */
7676
"module": "NodeNext",
7777
"outDir": "dist",
7878
"sourceMap": true,
79+
"verbatimModuleSyntax": true,
7980

8081
/* AND if you're building for a library: */
8182
"declaration": true,
@@ -84,7 +85,7 @@ Based on your answers to the above questions, here's how the complete `tsconfig.
8485
"composite": true,
8586
"declarationMap": true,
8687

87-
/* If NOT transpiling with TypeScript: */
88+
/* If NOT transpiling with tsc: */
8889
"module": "Preserve",
8990
"noEmit": true,
9091

@@ -363,6 +364,216 @@ This is because the bundler will take care of resolving the file paths and exten
363364

364365
This means that if you're using an external bundler or transpiler, you should use `module: "Preserve"` in your `tsconfig.json` file. This is also true if you're using a frontend framework like Next.js, Remix, Vite, or SvelteKit - it will handle the bundling for you.
365366

367+
## Importing Types With `import type`
368+
369+
When you're importing types from other files, TypeScript has some choices to make. Let's say you're importing a type of `Album` from `album.ts`:
370+
371+
```typescript
372+
// index.ts
373+
374+
import { Album } from "./album";
375+
```
376+
377+
What should the emitted JavaScript look like? We're only importing a type, which disappears at runtime. Should the import remain, but with the type removed?
378+
379+
```javascript
380+
// index.js
381+
382+
import {} from "./album";
383+
```
384+
385+
Or should the import be removed entirely?
386+
387+
These decisions matter, because modules can contain effects which run when they're first imported. For instance, `album.ts` might call a `console.log` statement:
388+
389+
```typescript
390+
// album.ts
391+
392+
export interface Album {
393+
title: string;
394+
artist: string;
395+
year: number;
396+
}
397+
398+
console.log("Imported album.ts");
399+
```
400+
401+
Now, if TypeScript removes (or, as they say in the TypeScript docs, elides) the import, the `console.log` statement won't run. This can be surprising if you're not expecting it.
402+
403+
The way TypeScript resolves this is with the `import type` syntax. If you're importing a type and you don't want the import to be emitted in the JavaScript, you can use `import type`:
404+
405+
```typescript
406+
// index.ts
407+
408+
import type { Album } from "./album";
409+
```
410+
411+
Now, only the type information is imported, and the import is removed from the emitted JavaScript:
412+
413+
```javascript
414+
// index.js
415+
416+
// No import statement
417+
```
418+
419+
### `import type { X }` vs `import { type X }`
420+
421+
You can combine `import` and `type` in two ways. You can either mark the entire line as a type import:
422+
423+
```typescript
424+
import type { Album } from "./album";
425+
```
426+
427+
Or, if you want to combine runtime imports with type imports, you can mark the type itself as a type import:
428+
429+
```typescript
430+
import { type Album, createAlbum } from "./album";
431+
```
432+
433+
In this case, `createAlbum` will be imported as a runtime import, and `Album` will be imported as a type import.
434+
435+
In both cases, it's clear what will be removed from the emitted JavaScript. The first line will remove the entire import, and the second line will remove only the type import.
436+
437+
### `verbatimModuleSyntax` Enforces `import type`
438+
439+
TypeScript has gone through various iterations of configuration options to support this behavior. `importsNotUsedAsValues` and `preserveValueImports` both tried to solve the problem. But since TypeScript 5.0, `verbatimModuleSyntax` is the recommended way to enforce `import type`.
440+
441+
The behavior described above, where imports are elided if they're only used for types, is what happens when `verbatimModuleSyntax` is set to `true`.
442+
443+
## ESM and CommonJS
444+
445+
There are two ways to modularize your code in TypeScript: ECMAScript Modules (ESM) and CommonJS (CJS). These two module systems operate slightly differently, and they don't always work together cleanly.
446+
447+
ES Modules use `import` and `export` statements:
448+
449+
```typescript
450+
import { createAlbum } from "./album";
451+
452+
export { createAlbum };
453+
```
454+
455+
CommonJS uses `require` and `module.exports`:
456+
457+
```typescript
458+
const { createAlbum } = require("./album");
459+
460+
module.exports = { createAlbum };
461+
```
462+
463+
Understanding the interoperability issues between ESM and CJS is a little beyond the scope of this book. Instead, we'll look at how to set up TypeScript to make working with both module systems as easy as possible.
464+
465+
### How Does TypeScript Know What Module System To Emit?
466+
467+
Imagine we have our `album.ts` file that exports a `createAlbum` function:
468+
469+
```typescript
470+
// album.ts
471+
472+
export function createAlbum(
473+
title: string,
474+
artist: string,
475+
year: number,
476+
): Album {
477+
return { title, artist, year };
478+
}
479+
```
480+
481+
When this file is turned into JavaScript, should it emit `CJS` or `ESM` syntax?
482+
483+
```javascript
484+
// ESM
485+
486+
export function createAlbum(title, artist, year) {
487+
return { title, artist, year };
488+
}
489+
```
490+
491+
```javascript
492+
// CJS
493+
494+
function createAlbum(title, artist, year) {
495+
return { title, artist, year };
496+
}
497+
498+
module.exports = {
499+
createAlbum,
500+
};
501+
```
502+
503+
The way this is decided is via `module`. You can hardcode this by choosing some older options. `module: CommonJS` will always emit CommonJS syntax, and `module: ESNext` will always emit ESM syntax.
504+
505+
But if you're using TypeScript to transpile your code, I recommend using `module: NodeNext`. This has several complex rules built-in for understanding whether to emit CJS or ESM:
506+
507+
The first way we can influence how TypeScript emits your modules with `module: NodeNext` is by using `.cts` and `.mts` extensions.
508+
509+
If we change `album.ts` to `album.cts`, TypeScript will emit CommonJS syntax, and the emitted file extension will be `.cjs`.
510+
511+
If we change `album.ts` to `album.mts`, TypeScript will emit ESM syntax, and the emitted file extension will be `.mjs`.
512+
513+
If we keep `album.ts` the same, TypeScript will look up the directories for the closest `package.json` file. If the `type` field is set to `module`, TypeScript will emit ESM syntax. If it's set to `commonjs` (or unset, matching Node's behavior), TypeScript will emit CJS syntax.
514+
515+
| File Extension | Emitted File Extension | Emitted Module System |
516+
| -------------- | ---------------------- | ----------------------------------- |
517+
| `album.mts` | `album.mjs` | ESM |
518+
| `album.cts` | `album.cjs` | CJS |
519+
| `album.ts` | `album.js` | Depends on `type` in `package.json` |
520+
521+
### `verbatimModuleSyntax` With ESM and CommonJS
522+
523+
`verbatimModuleSyntax` can help you be more explicit about which module system you're using. If you set `verbatimModuleSyntax` to `true`, TypeScript will error if you try to use `require` in an ESM file, or `import` in a CJS file.
524+
525+
For example, consider this file `hello.cts` that uses the `export default` syntax:
526+
527+
```tsx
528+
// hello.cts
529+
const hello = () => {
530+
console.log("Hello!");
531+
};
532+
533+
export { hello }; // red squiggly line under export { hello }
534+
```
535+
536+
When `verbatimModuleSyntax` is enabled, TypeScript will show an error under the `export default` line that tells us we're mixing the syntaxes together:
537+
538+
```tsx
539+
// hovering over export { hello } shows:
540+
ESM syntax is not allowed in a CommonJS module when 'verbatimModuleSyntax' is enabled.
541+
```
542+
543+
In order to fix the issue, we need to use the `export =` syntax instead:
544+
545+
```tsx
546+
// hello.cts
547+
548+
const hello = () => {
549+
console.log("Hello!");
550+
};
551+
export = { hello };
552+
```
553+
554+
This will compile down to `module.exports = { hello }` in the emitted JavaScript.
555+
556+
The warnings will show when trying to use an ESM import as well:
557+
558+
```tsx
559+
import { z } from "zod"; // rsl under import statement
560+
561+
// hovering over the import shows:
562+
// ESM syntax is not allowed in a CommonJS module when 'verbatimModuleSyntax' is enabled.
563+
```
564+
565+
Here, the fix is to use `require` instead of `import`:
566+
567+
```tsx
568+
import zod = require("zod");
569+
570+
const z = zod.z;
571+
```
572+
573+
Note that this syntax combines `import` and `require` in a curious way - this is a TypeScript-specific syntax that gives you autocomplete in CommonJS modules.
574+
575+
`verbatimModuleSyntax` is a great way to catch these issues early, and to make sure you're using the right module system in the correct files. It pairs very well with `module: NodeNext`.
576+
366577
## `noEmit`
367578

368579
The `noEmit` option in `tsconfig.json` tells TypeScript not to emit any JavaScript files when transpiling your TypeScript code.

0 commit comments

Comments
 (0)