The short answer
Await the import() and use the result; tsc types it from the module it loads. For a variable that holds the module after the first load, write its type as typeof import("./charts.js"):
/** @type {typeof import("./charts.js") | undefined} */
let charts;
/** @param {number[]} sales */
export async function salesLine(sales) {
charts ??= await import("./charts.js");
return charts.sparkline(sales);
}
const { sparkline } = await import("./charts.js");
console.log(sparkline([3, 9, 4]), await salesLine([1, 2, 3]));let charts: typeof import("./charts.js") | undefined;
export async function salesLine(sales: number[]) {
charts ??= await import("./charts.js");
return charts.sparkline(sales);
}
const { sparkline } = await import("./charts.js");
console.log(sparkline([3, 9, 4]), await salesLine([1, 2, 3]));jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": ["node"]
},
"include": ["*.js"]
} sparkline from the destructured import has the module’s own signature, and charts.sparkline(sales) is checked through the cached module variable.
Why it works
A dynamic import("./charts.js") with a literal path resolves like a static import, so tsc knows which file it loads and types the result as Promise<typeof import("./charts.js")>, the module’s namespace object. typeof import("./charts.js") is the same type written in JSDoc: the type of the whole module, with every export as a property. Nothing about it loads the module early; it is only a type.
The same thing in TypeScript
The TypeScript equivalent tab annotates the variable with typeof import("./charts.js") | undefined, the same type expression the JSDoc uses. The import() calls themselves are plain JavaScript and stay the same.
What goes wrong
A misspelled export is TS2339 on the module object, and under nodenext a relative path without its file extension is TS2835, exactly as for a static import. A path built at runtime is not checked at all: loose is any, so loose.anything passes:
const { sparkLine } = await import("./charts.js");error TS2339: Property 'sparkLine' does not exist on type 'typeof import("/project/charts", { with: { "resolution-mode": "import" } })'.
const charts = await import("./charts");error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './charts.js'?
const name = "charts";
const loose = await import(`./${name}.js`);
console.log(sparkLine, charts, loose.anything);jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": ["node"]
},
"include": ["*.js"]
} Gotchas
- The error shows a full path. tsc prints the module’s type as
typeof import("<path>/charts", …); read it as “the module charts.js”. - Default exports arrive as
default.const { default: formatPrice } = await import("./price.js")gets a module’s default export. - Bundlers may split the code; tsc does not care. Code splitting happens in a bundler, and the types are the same with or without it.
Questions
What is the type of import() with a path built at runtime?
Promise<any>. tsc can only type a dynamic import whose specifier is a string literal; for a path built at runtime, cast the result to the module type you expect, or keep a map of literal imports.
Does import() work in CommonJS files?
Yes. Node.js supports import() in CommonJS modules, and tsc types it the same way. It is the usual way for CommonJS code to load an ES module.
Sources
- import() · MDN Web Docs
- Modules Reference (module and moduleResolution) · TypeScript handbook
Last reviewed 21 October 2026.