Private modules and the `-internals` convention

AI-generated summary

The -internals convention designates folders containing code that is not public API. Code within these folders may be renamed, reshaped, or deleted without notice or deprecation. Plugins and themes must never depend on this code. Only the folder’s owner imports from it; other code, including core modules using a primitive, must import the owner’s public entry point instead. Since no linter enforces this, the folder name serves as the sole signal.

Placement is determined by ownership:

  1. Single Owner: If one component or module owns the concept, the -internals folder sits inside that owner. Only that owner imports from it.
  2. Shared Mechanism: If multiple members share a mechanism, the folder goes in the -internals of the smallest namespace containing all consumers. The folder is named after the mechanism, and any member of that namespace may import it.
  3. App-Wide: If consumers span several namespaces, the mechanism belongs in lib/-internals/<mechanism>/. This should only be used when no narrower namespace fits.

Users should not nest -internals under a namespace-scoped lib/ (e.g., form-kit/lib/), as lib/ holds flat, supported helpers, and -internals already indicates private status. When a shared mechanism emerges from an owner’s internals, it should be moved up to the shared namespace rather than importing across. This move should be behavior-neutral, verified by existing tests. Shared mechanism folders keep types next to the code using them, adding a types.ts file only when multiple files in the folder read from it.

What -internals means

A folder named -internals holds code that is not public API. It may be renamed, reshaped or deleted without notice or a deprecation cycle. Plugins and themes must never depend on it.

Only the folder’s owner imports from it. Everything else, including core code that uses a primitive, imports the owner’s public entry instead. The UI kit guide states the same rule for the kit’s primitives. Nothing lints this, so the folder name is the only signal.

Where it goes

Put -internals immediately inside the unit that owns the concept. Ownership decides placement, not who imports it.

One named owner. A component or module owns the concept, so its internals sit inside it, and only that owner imports them:

ui-kit/d-reorderable-list/-internals/     owned by DReorderableList
lib/blocks/-internals/                    owned by the blocks module

The UI kit guide’s “Splitting a large primitive” section describes how a primitive lays out its own -internals folder.

No single owner. Several members share the mechanism, so it goes in the -internals of the smallest namespace that contains every consumer, under a folder named for the mechanism. The owner is then that namespace, and any member of it may import the folder:

ui-kit/-internals/cursor/         shared by dRovingFocus and DReorderableList,
                                  both ui-kit members
lib/-internals/drag-and-drop/     shared by ui-kit modifiers and a service, so no
                                  namespace narrower than the app contains them

lib/ is the app-wide root, which is why a mechanism whose consumers span several namespaces lands there. Reach for it only when nothing narrower fits. A mechanism used only within ui-kit belongs to ui-kit.

Choosing

  1. Does one component or module own the concept? Then <owner>/-internals/.
  2. Otherwise, list every consumer and take the smallest namespace containing all of them. Then <namespace>/-internals/<mechanism>/.
  3. If that namespace is the app itself, it is lib/-internals/<mechanism>/.

Do not nest -internals under a namespace-scoped lib/. A namespace lib/ (as in form-kit/lib/) holds flat, supported helpers. -internals already says the code is private, so the extra level adds nothing.

Splitting a shared mechanism out

When a second consumer appears for something inside one owner’s -internals, move the shared part up rather than importing across. Keep the move behaviour-neutral and let the existing suite prove it. ItemScope and the stepping helpers were lifted out of ui-kit/modifiers/d-roving-focus/ into ui-kit/-internals/cursor/ this way, with the modifier’s own tests as the gate.

A shared mechanism folder keeps its types next to the code that uses them. Add a types.ts only when several files in the folder read from it.


This document is version controlled - suggest changes on github.