Cross-Package Imports Are Type-Only, Runtime Goes Through ctx

import type compiles to nothing, so it creates no runtime dependency edge. That is how dozens of UI packages can know each other's types while never loading each other — the plugin boundary lives in one keyword.

Posted by Jessie Jia on 2026-09-24

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
2
import type { Context } from '@app/core'
import type {} from '@app/ui-conversation/client'

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.json can list it under devDependencies only. In the package I was reading even React is a devDependency, and there is no dependencies field at all
  • circular dependencies stop being circular. A and B can import type from 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
2
3
4
5
export const inject = ['slots', 'locale', 'uiConversation']   // what I need

export function apply(ctx: Context) {
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({ /* … */ }))
}

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.