@itrocks/compose
Version:
Class compositions via configuration file, enabling mixins addition and module exports replacement
196 lines (135 loc) • 7.88 kB
Markdown
[](https://www.npmjs.org/package/@itrocks/compose)
[](https://www.npmjs.org/package/@itrocks/compose)
[](https://github.com/itrocks-ts/compose)
[](https://github.com/itrocks-ts/compose/issues)
[](https://25.re/ditr)
# compose
Class compositions via configuration file, enabling mixins addition and module exports replacement.
## Installation
```bash
npm install @itrocks/compose
```
## Usage
The `compose()` function must be called as early as possible, before any configured class is loaded.
In practice: call `compose()` before any `import()` or `require()` of application modules.
A minimal and typical setup can be seen in
[@itrocks/framework](https://github.com/itrocks-ts/framework/blob/main/src/framework.ts).
## Example
Given the [@itrocks/user](https://github.com/itrocks-ts/user/blob/main/src/user.ts) package
providing a `User` class, and an override of this class in a `user-override.ts` file stored at your project root:
```ts
// user-override.ts
import { User } from '@itrocks/user'
export class UserOverride extends User {}
```
Your main entry point may start with:
```ts
// main.ts
import { compose } from '@itrocks/compose'
compose(__dirname, {
'@itrocks/user': '/user-override'
})
// ... in code using User through dynamic import/require,
// User will be replaced by UserOverride and include any new or overridden features.
```
## Limitations
### CommonJS execution
This package relies on dynamic module loading. In Node.js:
- `require()` is dynamic and can be overridden,
- native ES `import` is static and cannot.
You can write ES `import` syntax in TypeScript, then transpile it to CommonJS, where imports become `require()`.
**Recommended minimal TypeScript configuration:**
```json
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "ES2022"
}
}
```
### Static import declarations
Static imports must be declared first in modules. As a result, `compose()` will only affect:
- modules loaded dynamically after it is called,
- static imports declared inside dynamically loaded modules.
## API
### compose(baseDir, config)
```ts
compose(baseDir: string, config: Record<string, string | string[]>): void
```
**Parameters:**
- `baseDir`: a directory used to resolve paths starting with `/`
- `config`: an object where:
- the **key** is the module (and optional export) to replace
- the **value** is the replacement module (and optional export), or an array of replacements
### Configuration module export format
Both replaced and replacement module / export apply these rules:
- A module path starting with `/` refers to the JavaScript file path, relative to `baseDir`'s argument.
- A module path not starting with `/` refers to the name of node_modules package or package module.
- The name of the export can be explicitly defined after the module path, separated by ':'.
- If no export is given: the default export is used if set,
otherwise the first exported value encountered at runtime is used.
- If the `default` export is given but the module has no default export,
the first exported value encountered at runtime is used.
Note: omitting `:exportName` is strictly equivalent to using `:default`.
### Single replacement
If one replacement is given for a module, it is applied:
- if the replacement inherits the original type, it is selected as the replacement type,
- otherwise, the original type is kept as the base type, and the replacement is applied as a **mixin** via [@itrocks/use](https://github.com/itrocks-ts/use),
### Multiple replacements
If multiple replacements are given for a module, they are applied in the order they are defined:
- the first entry that inherits the original type is selected as the replacement base type,
- if no entry inherits the original type: the original type is kept as the base type,
- the remaining entries are applied as **mixins** via [@itrocks/use](https://github.com/itrocks-ts/use).
### Summary table
| Configuration entry | Effect |
|--------------------------------------------|----------------------------------------------------------------------------------------------------------|
| `'pkg' : 'override'` | Replaces the module default export (or first export) with the override default export (or first export). |
| `'pkg:default' : 'override'` | Same as above, explicit `default` on both sides. |
| `'pkg:User' : 'override:UserOverride'` | Replaces the named export `User` with `UserOverride`. |
| `'pkg' : ['override']` | Single replacement: if it inherits the original type → replacement, otherwise applied as a mixin. |
| `'pkg' : ['override', 'mixinA', 'mixinB']` | First inheriting entry becomes the base type, others are applied as mixins (in order). |
| `'pkg' : ['mixinA', 'mixinB']` | No replacement found: original type kept, all entries applied as mixins. |
| `'/local-module' : 'pkg'` | Local module (resolved from `baseDir`) replaces a `node_modules` package. |
| `'pkg' : '/local-module'` | Package export replaced by a local implementation. |
| `'pkg' : 'override:mixinOnly'` | No inheritance detected → original kept, override applied as mixin. |
## Common mistakes
### Calling compose() too late
```ts
import { compose } from '@itrocks/compose'
import { User } from '@itrocks/user'
compose(__dirname, { '@itrocks/user': '/user-override' }) // too late
```
If a module is already loaded, its exports are fixed.\
`compose()` will not retroactively replace anything.
✔️ Always call `compose()` before loading any module you want to affect.
### Using native ES modules at runtime
Running Node.js in native ESM mode means:
- `import` is static
- module loading cannot be intercepted
In that case, `compose()` cannot work.
✔️ Use TypeScript with CommonJS output (even if you write import syntax).
### Expecting static imports to be replaced
```ts
import { User } from '@itrocks/user'
```
Static imports are resolved before any runtime code runs.
✔️ Only modules loaded:
- dynamically (`require`, or `import` transpiled to `require` at runtime)
- or statically inside dynamically loaded modules
can be affected.
### Forgetting that :default is implicit
`'@itrocks/user': '/user-override'`
This is strictly equivalent to:
`'@itrocks/user:default': '/user-override:default'`
✔️ If a module has no default export, `compose()` will use the first exported value it finds.
### Assuming an override always replaces the base type
If a replacement does not inherit the original type:
- it is not used as the base
- it is applied as a mixin instead
✔️ To fully replace a type, the replacement must extend or inherit from it.
### Mixing up path resolution rules
'@itrocks/user': 'user-override' // node resolution
'@itrocks/user': '/user-override' // resolved from baseDir
✔️ Paths starting with `/` are resolved from `baseDir`.\
✔️ Others are resolved via Node’s module resolution (`node_modules`).