Typing a dynamic import() whose specifier is only known at run time
loadPlugin loads a plugin module by name at run time. With a computed specifier the compiler cannot resolve any file, so await import(...) hands back any and every property access after it is unchecked — a missing or malformed plugin fails silently, deep inside the caller.
Constraints: nothing of type any may escape the function, the module's shape must be checked at run time (the type system knows nothing about a file picked by string), and the module may not be asserted to Plugin without that check. Throw on a module that does not match.
export interface Plugin {
name: string;
run(input: string): string;
}
// Loads ./plugins/<name>.js at run time.
export async function loadPlugin(name: string): Promise<Plugin> {
// your code here
}
Write the implementation.
With a literal specifier import('./m.js') is typed — the compiler resolves the file and infers Promise<typeof import('./m.js')>. With a computed specifier it can resolve nothing, so the result is any and everything after it is unchecked. Await into unknown, narrow with a type guard that inspects the shape at run time, and throw when it fails.
- ✗Assuming a template-literal specifier still resolves to a typed module
- ✗Asserting the imported module to
Pluginwith no run-time check of its shape - ✗Letting the
anyfrom a computedimport()leak out through the return type
- →What exactly does
typeof import('./m.js')denote, and when can you write it by hand? - →How would you keep the plugin registry type-safe if plugins are also allowed to register themselves?
Why the type is lost
With a literal specifier the compiler resolves import() like any other import:
const mod = await import('./plugins/upper.js');
// ^? typeof import('./plugins/upper.js')
But a template-literal specifier — ./plugins/ plus name plus .js — is computed. There is no file to look at, so the result becomes any — and every property access after it silently type-checks.
The fix
Await into unknown and check the shape with a type guard:
export interface Plugin {
name: string;
run(input: string): string;
}
function isPlugin(value: unknown): value is Plugin {
if (typeof value !== 'object' || value === null) return false;
const candidate = value as Record<string, unknown>;
return typeof candidate.name === 'string' && typeof candidate.run === 'function';
}
export async function loadPlugin(name: string): Promise<Plugin> {
const mod: unknown = await import(`./plugins/${name}.js`);
const exported = (mod as { default?: unknown }).default;
if (!isPlugin(exported)) {
throw new Error(`Plugin "${name}" has no valid default export`);
}
return exported;
}
mod: unknown is the load-bearing line: it kills the any right at the boundary, and nothing can be done with the value until it is narrowed. isPlugin is the single place the shape is asserted — and it is asserted on the strength of a run-time check, not on faith.