I was reading an “everything is a plugin” frontend codebase — dozens of UI packages that almost never import each other. One package opened with these two lines:
1 | import type { Context } from '@app/core' |
The braces on the second one are empty. I assumed it was a leftover somebody forgot to delete. It is not. It is the thing that makes the architecture work.
import type compiles to nothing
Those two lines emit no JavaScript. No require, no import, and at runtime neither package is loaded at all.
A normal import is different. It leaves a real module load behind:
1 | import { GoalDock } from './GoalBar.tsx' // actually loads this module at runtime |
One keyword of difference, and it decides what the dependency graph looks like.
Consequence 1: a different dependency graph
import type creates no runtime edge, so:
- the bundler never pulls that package into the output
package.jsoncan list it underdevDependenciesonly. In the package I was reading even React is a devDependency, and there is nodependenciesfield at all- circular dependencies stop being circular. A and B can
import typefrom each other, because neither loads the other at runtime
That last one is what a plugin system actually wants. Package A needs to know what services B offers. It does not need — and must not have — B pulled in at runtime, because whether B is installed is a runtime question.
Consequence 2: the empty braces are for declaration merging
1 | import type {} from '@app/ui-conversation/client' |
This imports zero symbols. Its only job is to pull that package’s declare module blocks into the current compilation scope.
The comment in the source says so plainly: the line does not want a value. It wants ctx.uiConversation to have a type, and the slot id 'conversation.input.dock' to be a known string.
A normal import would also give you those types, but it would add a runtime load edge — for a module from which you use nothing. The empty braces are the precise way to say “I want your declarations and literally nothing else.”
Consequence 3: runtime wiring has to go somewhere else
This is the part that matters. If packages do not import each other, how do they reach each other at runtime?
Through a cordis-style ctx container:
1 | export const inject = ['slots', 'locale', 'uiConversation'] // what I need |
A package never imports a peer. It declares which services it requires. Who provides them, when, and whether at all, is the container’s problem.
That leaves two independent channels:
flowchart LR
subgraph compile["Compile time"]
A1["Package A"] -. "import type" .-> B1["Package B types"]
A1 -. "import type {}" .-> C1["declare module
declaration merging"]
end
subgraph runtime["Runtime"]
A2["Package A"] -->|"inject: ['uiConversation']"| CTX["ctx container"]
B2["Package B"] -->|"registers service"| CTX
end
Types travel by import type and exist only at compile time. Values travel through ctx and exist only at runtime. The two never touch, and that is the entire reason those packages can avoid importing each other.
How to remember it
import type means “I need to know what you look like.” A normal import means “I need you loaded.”
The rule in this codebase: inside a package, use both. Across packages, only the first.
One thing to watch
This moves a class of error from build time to runtime. import type convinces the compiler that ctx.uiConversation exists, but if that plugin was never installed, it is undefined when you reach for it. The inject array is what covers the gap — the container checks it before applying the plugin. Which means forgetting an entry in inject still typechecks, and you find out when it runs.
That is the trade: a known runtime failure mode, in exchange for plugins that are genuinely decoupled.