import defer / import attributes

Passes host-specific attributes on an import and defers a module's evaluation until first property access.

Signature
import obj from "./data.json" with { type: "json" };
const obj = await import("./data.json", { with: { type: "json" } });

import type { T } from "pkg" with { "resolution-mode": "require" };
type U = import("pkg", { with: { "resolution-mode": "import" } }).T;

import defer * as feature from "./feature.js";
  • Added in TypeScript 5.3

Parameters

ParameterTypeDescription
$with { ... }attribute recordHost-specific key/value pairs passed to the runtime. TypeScript does not check the contents — `type: "json"` and friends are interpreted by the browser or runtime, not the compiler.
$typeoptionalstringThe conventional attribute naming the expected module format, most commonly `"json"`.
$resolution-modeoptional"import" | "require"TypeScript-specific attribute, valid on `import type` and `import()` types, forcing the specifier to be resolved with ESM or CommonJS semantics regardless of the containing file's format.
$deferkeywordPlaced after `import`, defers evaluation of the module body until a property of the namespace is first read. Only the namespace form `import defer * as ns from "..."` is permitted.
$* as nsnamespace bindingThe only binding form `import defer` accepts. Named and default imports are rejected, because either would require evaluating the module to produce the binding.

Errors

Raises "Import attributes are only supported when the '--module' option is set to 'esnext', 'nodenext', or 'preserve'", and for the deferred form "Deferred imports are only supported when the '--module' option is set to 'esnext' or 'preserve'"; a named or default binding after `defer` is also an error.

Changelog

  • PHP 4.5Import assertions (`assert { type: "json" }`) supported, following the earlier TC39 proposal.
  • PHP 5.3Import attributes (`with { ... }`) supported, superseding assertions; `resolution-mode` on `import type` becomes stable and works under every `moduleResolution` mode including `bundler` and `node10`.
  • PHP 5.9`import defer * as ns from "..."` added, limited to `--module preserve` and `esnext` and never downleveled.

Two newer pieces of module syntax that TypeScript types but does not implement.

Import attributes

with { type: "json" } passes information to the runtime about the format a specifier is expected to have. TypeScript deliberately does not validate the contents — the keys and values are host-specific, so the compiler parses them, type-checks the import itself, and leaves the attributes alone. They replaced the older assert { ... } syntax; the assertion spelling still parses but the proposal moved on, so write with.

The one attribute TypeScript itself understands is resolution-mode, which is valid only on import type and import() types. It forces a specifier to resolve under ESM or CommonJS rules regardless of the containing file — the way to reach both halves of a dual-published package's types from one file.

import defer

import defer * as feature from "./feature.js" loads and links the module but does not run its body until you first read a property off the namespace. Use it for a module with expensive or platform-specific initialisation that most code paths never touch.

What trips people up

Neither feature is downleveled. Both need --module esnext or preserve (attributes also allow nodenext) and a runtime or bundler that implements them.

Example

Example · typescript
// tsconfig: { "module": "esnext", "resolveJsonModule": true }

import config from "./config.json" with { type: "json" };
import defer * as report from "./report.js"; // body not run yet

const dynamic = await import("./config.json", { with: { type: "json" } });

console.log(config.name, dynamic.default.name);
// -> "soundscode" "soundscode"

console.log(report.render(config.name)); // first access: report.js evaluates now
// -> "Side effects have occurred!"
// -> "report for soundscode"

When to use it

  • Import a JSON module in a browser or Node ESM context that requires the type attribute
  • Skip expensive initialisation of a rarely-used feature module until it is first touched
  • Pull types from both the ESM and CommonJS halves of a dual-published package in one file
  • Get a type from a CommonJS-only package inside an ES module file without changing resolution settings

Frequently asked questions

Why do I get "Import attributes are only supported when module is set to esnext, nodenext, or preserve"?

Because attributes cannot be downleveled — there is no CommonJS equivalent of `with { type: "json" }`. Set `"module"` to `esnext`, `nodenext` or `preserve` in tsconfig, or remove the attribute if your build target is CommonJS.

What is the difference between import defer and a dynamic import?

`import defer` loads and links the module eagerly but delays running its top-level code until you read a property of the namespace, and stays synchronous. `await import(...)` defers the fetch as well and returns a promise, so the call site must be async. Reach for `import defer` when the cost is initialisation rather than loading.

Does TypeScript check import attribute values?

No. Attributes are host-specific, so TypeScript parses them and passes them through untouched — a misspelled `type: "jsonn"` compiles fine and fails at runtime. The one exception is `resolution-mode`, which TypeScript defines itself and accepts only the values `"import"` and `"require"` on type-only imports.

More examples

When import defer actually runs the module

The ordering in the output is the whole point: "app: started" prints before "heavy-feature: evaluating", and if the flag is absent the second line never prints at all. The module is fetched and linked eagerly — only the execution of its top-level statements waits. That is the difference from `await import(...)`, which defers loading too and forces the call site to be async.

Example · typescript
// heavy-feature.ts
console.log("heavy-feature: evaluating");
const table = buildLookupTable();
function buildLookupTable(): Map<string, number> {
  return new Map([["a", 1], ["b", 2]]);
}
export function lookup(k: string): number | undefined { return table.get(k); }

// app.ts  (--module esnext or preserve)
import defer * as heavy from "./heavy-feature.js";

console.log("app: started");
// -> "app: started"        (heavy-feature has NOT logged yet)

if (process.argv.includes("--with-lookup")) {
  console.log(heavy.lookup("b"));
  // -> "heavy-feature: evaluating"
  // -> 2
}

resolution-mode: reading both halves of a dual package

Both imports name the same specifier and get different declaration files, because `resolution-mode` overrides the resolution semantics the containing file would normally impose. This is how you write a shim that must satisfy consumers of both entry points. Note the attributes are on `import type` only — the resolution mode is a compile-time concern and there is no emitted import to carry it.

Example · typescript
// A package that ships different types for its ESM and CJS entry points.
import type { ClientOptions as EsmOptions } from "dual-pkg" with {
  "resolution-mode": "import"
};
import type { ClientOptions as CjsOptions } from "dual-pkg" with {
  "resolution-mode": "require"
};

// Also available in import() type position:
type LegacyClient = import("dual-pkg", {
  with: { "resolution-mode": "require" }
}).Client;

export interface MergedOptions extends EsmOptions, CjsOptions {}

const opts: MergedOptions = { endpoint: "https://example.test", retries: 2 };
console.log(Object.keys(opts).sort());
// -> ["endpoint", "retries"]
declare const legacy: LegacyClient;

Discussion

  • Be the first to comment on this lesson.