@doeixd/make-with
Version:
Lightweight function application utilities
1,476 lines (1,187 loc) • 48.6 kB
Markdown
# 🧰 Make With
[](https://www.npmjs.com/package/@doeixd/make-with)
[](https://www.typescriptlang.org/)
[](https://opensource.org/licenses/MIT)
**Make With** is a small, zero-dependency library for building stateful objects and function-composition patterns in a clean, functional, and simple way. It helps you write predictable, testable code by avoiding the complexities of `this`, classes, and manual state binding.
<br />
## ✨ Guiding Principles
This library is built on a few simple but powerful concepts:
* **Explicit Over Implicit:** Dependencies (state, config) are always passed as an explicit argument (`subject` or `self`), completely eliminating the confusion of the `this` keyword.
* **Functions as Building Blocks:** Your logic lives in plain, pure functions. The library provides tools to compose these functions into cohesive, testable APIs without the ceremony of classes.
* **Immutability by Default:** State-changing operations should produce a *new* API instance with the *new* state, leaving the original untouched. This leads to predictable data flow and prevents a whole category of bugs. However mutable patterns remain possible and well supported as well.
<br />
## 📦 Installation
```sh
npm install @doeixd/make-with
```
<br />
## 🚀 Quick Start: From Primitives to a Powerful API
This library provides composable primitives that build on each other. This journey shows how they work together to create a full-featured API.
#### Step 1: The Problem & The Simplest Primitive (`provide`)
Imagine you have functions that all need the same config. Passing it every time is repetitive. The `provide` primitive solves this by "baking in" the context.
```typescript
import { provide } from '@doeixd/make-with';
const config = { token: 'abc', baseUrl: '...' };
// `provide` takes a context and returns a function that takes your functions
const [getUser, getRepos] = provide(config)(
(cfg, username) => `Getting user ${username} with token ${cfg.token}`,
(cfg, username) => `Getting repos for ${username} with token ${cfg.token}`
);
// Now the calls are much cleaner.
getUser('alice');
// But you get back a simple array, which isn't a great API.
```
#### Step 2: The Need for Names (`collectFns`)
To build a proper API object, we need named methods like `api.getUser()`. The `collectFns` (alias: `make`) primitive helps by turning loose functions into a named map.
```typescript
import { collectFns } from '@doeixd/make-with';
function myCoolFunction() {}
const myFns = collectFns(myCoolFunction); // -> { myCoolFunction: [Function: myCoolFunction] }
```
#### Step 3: The Core Utility (`provideTo`)
Now, let's combine these ideas. `provideTo` (alias: `makeWith`) is the core utility that directly binds a context to a map of named functions, giving us the clean API we wanted from the start.
```typescript
import { provideTo } from '@doeixd/make-with';
const config = { token: 'abc', baseUrl: '...' };
const apiClient = provideTo(config)({
getUser: (cfg, username) => { /* ... */ },
getRepos: (cfg, username) => { /* ... */ },
});
// The result is a clean, organized, and easy-to-use object.
apiClient.getUser('alice');
```
#### Step 4: Managing Changing State (`makeChainable`)
This is great for static configs, but what about dynamic state? `makeChainable` marks methods as state updaters. When called, they return a **whole new API** bound to the new state.
```typescript
import { provideTo, makeChainable } from '@doeixd/make-with';
const counter = provideTo({ count: 0 })({
...makeChainable({
increment: (s) => ({ count: s.count + 1 }),
add: (s, amount) => ({ count: s.count + amount }),
}),
get: (s) => s.count,
});
// Now, it's fully chainable and immutable.
const finalCounter = counter.increment().add(5);
console.log(finalCounter.get()); // 6
console.log(counter.get()); // 0 (The original is untouched)
```
<br />
## 🧠 The Philosophy: Composition Over Confinement
`Make With` is designed around the idea that your logic should be composed of simple, portable functions, not confined within rigid structures.
### 1. Building with Functions, Not Rigid Classes
In OOP, logic is tied to class instances via `this`, making methods difficult to move or reuse. With `Make With`, your logic lives in pure functions that are completely portable.
**The `Make With` Freedom:**
```typescript
// This function can live anywhere. It has no dependency on a class.
// It's just a pure function: (state, input) => newState
const add = (state, amount) => ({ ...state, value: state.value + amount });
// Now, we can easily "provide" it to a state object.
const calculator = provideTo({ value: 10 })({
...makeChainable({ add }),
});
const newCalculator = calculator.add(5); // { value: 15 }
```
> **The Takeaway:** Your business logic becomes a library of composable, independently testable functions, not a collection of methods trapped inside a class.
### 2. Stackable Behaviors, Not Brittle Inheritance
Classes use inheritance to share code, which creates tight coupling. `Make With` uses layers of composition, which is far more flexible. Imagine adding logging to an API client.
**The `makeLayered` Composition Approach:**
```typescript
// A generic logging enhancer. It doesn't care what it's wrapping.
const withLogging = {
get: async (self, path) => { // `self` is the API from the previous layers
console.log(`[LOG] Requesting: ${path}`);
const result = await self.get(path); // Calls the original `get` method
console.log(`[LOG] Success!`);
return result;
},
};
// Now, compose the final client by stacking layers:
const client = makeLayered({ baseUrl: "..." })
({ get: (s, path) => fetch(`${s.baseUrl}/${path}`).then(res => res.json()) }) // Core logic
(withLogging) // Add logging on top
(); // Finalize and build the API
// The final `client.get()` is the enhanced, logged version.
```
> **The Takeaway:** You can build complex objects by stacking independent behaviors, avoiding the rigid hierarchies and tight coupling of inheritance.
### 3. Type Safety with TypeScript
Because the final API is constructed step-by-step, TypeScript can precisely track its shape at every stage. This is especially powerful when building dynamic APIs.
```typescript
const createAuthApi = (user) => {
const builder = makeLayered({ user })
(makeChainable({ /* ... base methods ... */ }))
({ getUser: (s) => s.user });
// Conditionally add the admin layer
if (user.isAdmin) {
builder({ banUser: (self, username) => console.log(/* ... */) });
}
return builder(); // The final API type is inferred correctly!
};
const adminApi = createAuthApi({ name: 'Alice', isAdmin: true });
adminApi.banUser('Bob'); // ✅ Compiles perfectly.
const guestApi = createAuthApi({ name: 'Guest', isAdmin: false });
// guestApi.banUser('Bob'); // 💥 TypeScript Error! Property 'banUser' does not exist.
```
> **The Takeaway:** You get dynamic, compositional power without sacrificing static type safety.
<br />
## 🎩 Advanced Usage
### The `makeLayered` Builder
For the most complex scenarios, `makeLayered` gives you ultimate control. It builds an API in distinct, "self-aware" layers.
#### Understanding Layer Functions vs Method Objects
`makeLayered` supports two types of layers:
**Method Objects** - Direct method definitions:
```typescript
{ methodName: (subject, ...args) => result }
```
**Layer Functions** - Functions that receive the current API and return methods:
```typescript
(currentApi) => ({ methodName: (subject, ...args) => result })
```
**When to use Layer Functions:**
- When you need to reference methods from previous layers
- When creating conditional or dynamic method definitions
- When implementing decorators, middleware, or aspect-oriented patterns
- When building methods that orchestrate multiple existing methods
#### Pattern 1: Orchestration (Methods Calling Methods)
The `double` method here orchestrates calls to `get` and `add` from previous layers, using `self` to refer to the API instance being built.
```typescript
import { makeLayered, makeChainable } from '@doeixd/make-with';
const counter = makeLayered({ count: 3 })
(makeChainable({ add: (s, amount) => ({ ...s, count: s.count + amount }) })) // Base Layer
({ get: (s) => s.count }) // Getter Layer
({ double: (self) => self.add(self.get()) }) // Enhancer Layer: `self` is the API!
(); // Finalizer call to build the object
const finalCounter = counter.double(); // finalCounter.get() is 6
```
#### Pattern 1a: Using Layer Functions for Dynamic Orchestration
Layer Functions provide more flexibility by receiving the current API as a parameter:
```typescript
const counter = makeLayered({ count: 3 })
(makeChainable({
add: (s, amount) => ({ ...s, count: s.count + amount }),
multiply: (s, factor) => ({ ...s, count: s.count * factor })
}))
({ get: (s) => s.count })
// Layer Function - receives the current API and returns methods
((api) => ({
double: (s) => api.add(api.get()), // Can call api.add and api.get
quadruple: (s) => api.multiply(4), // Can orchestrate multiple operations
addAndDouble: (s, amount) => {
const withAdded = api.add(amount);
return withAdded.double();
}
}))
();
const result = counter.addAndDouble(2); // adds 2, then doubles: (3+2)*2 = 10
```
#### Pattern 2: Direct Mutation API (When You Want It)
While immutability is the default, `makeLayered` also supports direct mutation patterns. This is a deliberate design choice for scenarios where mutable state is more intuitive or performant.
```typescript
const mutableCounter = makeLayered({ count: 0 })
({
getSubject: (s) => s, // A helper to get the raw state object
get: (s) => s.count,
})
({
increment: (self) => {
self.getSubject().count++; // Mutate the state directly
return self; // Return self for chaining
},
add: (self, amount) => {
self.getSubject().count += amount;
return self;
},
})();
// Direct, efficient, and chainable
mutableCounter.increment().add(5);
console.log(mutableCounter.get()); // 6
```
This pattern shines when managing local component state, optimizing performance-critical code, or integrating with systems that expect mutation.
#### Pattern 3: Composing Dependent Factories with `enrich`
Sometimes, you need to create an object where one part depends on another (e.g., generating an ID first, then using it to assign permissions). The `enrich` utility composes two factory functions, merging their results.
```typescript
import { enrich } from '@doeixd/make-with';
const createUser = (name: string) => ({ name, id: Math.random() });
const addPermissions = (user: { id: number }) => ({
permissions: user.id > 0.5 ? ['admin'] : ['guest']
});
const createFullUser = enrich(createUser, addPermissions);
const user = createFullUser('Alice');
// user is { name: 'Alice', id: 0.78, permissions: ['admin'] }
```
<br />
## 🎯 Ideal Use Cases
While flexible, `Make With` excels in these areas:
* **Building SDKs or API Clients:** Create clean, configured clients where a base config is injected into a set of request functions.
* **Managing Complex UI Component State:** Handle intricate local state for a component (e.g., a multi-step form) in a predictable way.
* **Implementing the Builder Pattern:** Construct complex objects step-by-step in a fluent manner, with support for both immutable and mutable styles.
* **Creating Self-Contained Modules:** Encapsulate logic and state for a specific domain, like a "shopping cart" or "user session" module.
<br />
## 📚 More Examples
For a comprehensive collection of examples demonstrating every feature of the library, check out the **[examples.ts](./examples.ts)** file in the root of this project. It includes real-world patterns, custom helpers, and performance optimization techniques.
<br />
## 🛠️ The Full Toolkit
### Breakdown
| Function | Alias | Description |
|---|---|---|
| `provide` | `_with` | **(Primitive)** Partially applies a subject to an array of functions. |
| `collectFns`| `make` | **(Primitive)** Normalizes loose functions into a key-value object. |
| `merge` | - | **(Primitive)** Merges multiple method objects with later objects taking precedence. Now supports curried usage for functional composition patterns. |
| `createMerger` | - | **(Primitive)** Creates type-safe, auto-curried merger with custom merge strategies and validation. |
| `withFallback` | - | **(Primitive)** Creates intelligent fallback chains with custom validation and nested object support. |
| `provideTo`|`makeWith` | **(Core)** Binds a subject to functions to create a basic API. |
| `makeWithCompose`| - | **(Core)** Like `makeWith` but automatically composes methods with the same name. |
| `makeChainable`| `rebind`| **(Core)** Marks methods for immutable, chainable behavior. |
| `createProxy` | - | **(Core)** Creates dynamic APIs with ES6 Proxy that generate methods on-the-fly. |
| `createLens` | - | **(Core)** Creates lenses that focus operations on specific slices of state. |
| `compose` | - | **(Advanced)** Creates composable methods that can access previous methods with the same name. |
| `makeLayered`| - | **(Advanced)** Creates a multi-layered, self-aware API using a fluent interface. |
| `enrich` | - | **(Advanced)** Composes two dependent factory functions and merges their results. |
### Import Patterns
**Named Imports (Recommended):**
```typescript
import {
makeWith,
makeChainable,
makeLayered,
compose,
merge,
createMerger,
createProxy,
createLens,
withFallback
} from '@doeixd/make-with';
```
**Default Import (All functions):**
```typescript
import makeWithLib from '@doeixd/make-with';
const api = makeWithLib.makeLayered(state)
(makeWithLib.makeChainable(methods))
(makeWithLib.compose(enhancedMethods))
();
```
### API Reference
<br />
#### provide (alias: `_with`)
```typescript
function provide<S>(subject: S): <Fs extends ((subject: S, ...args: any[]) => any)[]>(
...fns: Fs
) => { /* ... bound functions ... */ }
```
**(Primitive)** Partially applies a subject to an array of functions, returning new functions with the subject pre-applied.
**Example:**
```typescript
const [getUser] = provide({ token: 'abc' })(
(cfg, username: string) => `Fetching ${username}...`
);
```
<br />
#### collectFns (alias: `make`)
```typescript
function collectFns<F extends (...args: any[]) => any>(...fns: F[]): Record<string, F>;
function collectFns<Obj extends Methods>(obj: Obj): Obj;
```
**(Primitive)** Normalizes loose functions into a key-value object, using the function's `name` property as the key.
**Example:**
```typescript
function greet(name: string) { /* ... */ }
const api = collectFns(greet); // { greet: [Function: greet] }
```
<br />
#### provideTo (alias: `makeWith`)
```typescript
function provideTo<S extends object>(subject: S): <Fns extends Methods<S>>(
functionsMap: Fns
) => ChainableApi<Fns, S>;
```
**(Core)** Creates an API by binding a subject to a map of functions. It intelligently handles both regular and `makeChainable` methods.
**Example:**
```typescript
const api = provideTo({ count: 0 })({
get: (s) => s.count
});
```
<br />
#### makeChainable (alias: `rebind`)
```typescript
function makeChainable<Obj extends Methods>(obj: Obj): Obj;
```
**(Core)** Marks methods for immutable, chainable behavior. When used with `provideTo`, these methods return a new API instance with the updated state.
**Example:**
```typescript
const counter = provideTo({ count: 0 })({
...makeChainable({
increment: (s) => ({ count: s.count + 1 }),
}),
});
const newCounter = counter.increment(); // Chainable!
```
#### `merge`
```typescript
function merge<T extends Methods>(...objects: T[]): T;
function merge<T extends Methods>(firstObject: T): <U extends Methods>(...additionalObjects: U[]) => T & U;
```
**(Primitive)** Merges multiple method objects with later objects taking precedence, or creates a curried merger function for partial application. This enhanced version supports both immediate merging and functional composition patterns.
**Direct Merging (Original Behavior):**
```typescript
const baseMethods = { get: (s) => s.value, set: (s, v) => ({ value: v }) };
const extensions = { increment: (s) => ({ value: s.value + 1 }) };
const validation = { set: (s, v) => v >= 0 ? ({ value: v }) : s }; // Override set
const allMethods = merge(baseMethods, extensions, validation);
const api = makeWith({ value: 0 })(allMethods);
```
**Curried Usage for Extension Patterns:**
```typescript
// Create reusable extensions
const addDefaults = merge({ role: 'user', active: true });
const withAuth = merge({ isAuthenticated: (s) => !!s.token });
// Compose extensions functionally
const userMethods = addDefaults(withAuth({ login: (s, token) => ({ ...s, token }) }));
// Build reusable method enhancers
const withTimestamp = merge({
addTimestamp: (s) => ({ ...s, createdAt: Date.now() }),
updateTimestamp: (s) => ({ ...s, updatedAt: Date.now() })
});
const withValidation = merge({
validate: (s, rules) => rules.every(rule => rule(s))
});
// Chain extensions functionally
const enhancedAPI = makeWith(initialState)(
withValidation(withTimestamp(baseMethods))
);
```
**Conditional Merging:**
```typescript
const createUserAPI = (isAdmin: boolean) => {
const base = { getProfile: (s) => s.profile };
const adminMethods = isAdmin ? { deleteUser: (s, id) => ({ ...s, deleted: [...s.deleted, id] }) } : {};
return makeWith(initialState)(merge(base)(adminMethods));
};
```
#### `createMerger`
```typescript
function createMerger<T>(mergeDefinition: MergeDefinition<T>): AutoCurriedMerger<T>
function createMerger<T>(tupleDefinitions: TupleMergeDefinition<T>): AutoCurriedMerger<T>
```
**(Primitive)** Creates a type-safe, auto-curried merger function with custom merge strategies and comprehensive error handling. Supports both object-based and tuple-based merge definitions.
**Example:**
```typescript
interface User {
name: string;
age: number;
tags: string[];
}
const userMerger = createMerger<User>({
name: (a, b, key) => a.name || b.name,
age: (a, b, key) => Math.max(a.age || 0, b.age || 0),
tags: (a, b, key) => [...(a.tags || []), ...(b.tags || [])]
});
const result = userMerger({ name: "Alice", age: 25, tags: ["admin"] }, { age: 30, tags: ["user"] });
// Result: { success: true, data: { name: "Alice", age: 30, tags: ["admin", "user"] } }
```
#### `createProxy`
```typescript
function createProxy<S>(handler: ProxyHandler<S>): (initialState: S) => DynamicAPI<S>
```
**(Core)** Creates a dynamic API using ES6 Proxy that generates methods on-the-fly based on a handler function. Includes composable utility functions for common patterns.
**Example:**
```typescript
interface User {
name: string;
age: number;
email: string;
}
// Basic usage with built-in getSet utility
const userAPI = createProxy<User>(getSet)({ name: "Alice", age: 25, email: "alice@example.com" });
const name = userAPI.getName(); // "Alice"
const updated = userAPI.setAge(26); // Returns new API with age: 26
// Composable utilities
const flexibleAPI = createProxy<User>(ignoreCase(noSpecialChars(getSet)))(userState);
flexibleAPI.getName(); // Standard
flexibleAPI.getname(); // Case insensitive
flexibleAPI.get_name(); // Special chars stripped
```
#### `createLens`
```typescript
function createLens<S, T>(getter: (state: S) => T, setter: (state: S, focused: T) => S): LensFunction<S, T>
```
**(Core)** Creates a lens that focuses method operations on a specific slice of state. Enables building APIs that operate on nested state structures while maintaining type safety and immutability.
**Example:**
```typescript
interface AppState {
user: { name: string; email: string };
posts: Post[];
ui: UIState;
}
// Create lens focused on user slice
const userLens = createLens<AppState, AppState['user']>(
state => state.user,
(state, user) => ({ ...state, user })
);
const userMethods = makeChainable({
updateName: (user, name: string) => ({ ...user, name }),
updateEmail: (user, email: string) => ({ ...user, email })
});
// API operations automatically work on the user slice
const appAPI = makeWith(appState)(userLens(userMethods));
const newState = appAPI.updateName("Alice").updateEmail("alice@example.com");
```
#### `makeWithCompose`
```typescript
function makeWithCompose<S extends object>(subject: S): (...methodObjects: Methods<S>[]) => ChainableApi<any, S>;
```
**(Core)** Like `makeWith` but automatically composes methods with the same name. Later methods receive previous methods as their last parameter.
**Example:**
```typescript
const api = makeWithCompose({ data: [] })(
{ save: (s, item) => ({ data: [...s.data, item] }) },
{ save: (s, item, prevSave) => prevSave(s, { ...item, timestamp: Date.now() }) },
{ save: (s, item, prevSave) => {
if (!item.name) throw new Error('Name required');
return prevSave(s, item);
}
}
);
```
#### `compose`
```typescript
function compose<T extends Record<string, any>>(methods: T): T & { [IS_COMPOSABLE]: true };
```
**(Advanced)** Creates composable methods that can access previous methods with the same name. Automatically handles both regular and chainable methods - the previous method always returns the appropriate result.
**Regular Methods Example:**
```typescript
const api = makeLayered({ count: 0 })
({
get: (s) => s.count,
increment: (s) => ({ count: s.count + 1 })
})
(compose({
get: (s, prevGet) => {
const value = prevGet(s); // Returns number
console.log('Current count:', value);
return value;
}
}))
();
```
**Chainable Methods Example (Automatic Handling):**
```typescript
const api = makeLayered({ count: 0 })
(makeChainable({
increment: (s) => ({ count: s.count + 1 }),
add: (s, amount) => ({ count: s.count + amount })
}))
(compose({
increment: (s, prevIncrement) => {
console.log('Before:', s.count);
const newState = prevIncrement(s); // Always returns state object
console.log('After:', newState.count);
return newState; // Automatically becomes chainable
}
}))
();
// Usage: api.increment().add(5).increment() - all chainable!
```
<br />
#### makeLayered
```typescript
function makeLayered<S extends object>(subject: S): LayeredApiBuilder<...>
```
**(Advanced)** Creates a multi-layered, self-aware API. Each layer receives the API constructed from previous layers as its context (`self`). Supports both method objects and layer functions for maximum flexibility.
**Method Object Example:**
```typescript
const api = makeLayered({ value: 10 })
(makeChainable({ add: (s, n) => ({ value: s.value + n }) }))
({ double: (self) => self.add(self.value) })
();
```
**Layer Function Example:**
```typescript
const api = makeLayered({ items: [] })
({ add: (s, item) => ({ items: [...s.items, item] }) })
// Layer function receives the current API
((currentApi) => ({
addMultiple: (s, items) => items.reduce((acc, item) => currentApi.add(item), s)
}))
();
```
**Error Handling:**
The library includes a custom `LayeredError` class for enhanced debugging and error tracking throughout the layered composition process.
<br />
#### enrich
```typescript
function enrich<P, S>(primaryFactory: P, secondaryFactory: S): FusedFunction
```
**(Advanced)** Composes two factory functions where the second depends on the first, merging their results into a single object.
**Example:**
```typescript
const createUser = (name: string) => ({ name, id: 1 });
const addStatus = (user: { id: number }) => ({ status: 'active' });
const createFullUser = enrich(createUser, addStatus);
```
#### `withFallback`
```typescript
function withFallback<T>(primaryObject: T, validator?: ValueValidator): FallbackChainBuilder<T>
```
**(Primitive)** Creates a fallback chain proxy that traverses multiple objects to find valid values. Uses a layered API similar to `makeLayered` for building fallback chains.
**Example:**
```typescript
const userConfig = { apiUrl: "https://api.user.com" };
const defaults = { apiUrl: "https://default.com", timeout: 5000, debug: false };
const config = withFallback(userConfig)(defaults)();
console.log(config.apiUrl); // "https://api.user.com" (from user)
console.log(config.timeout); // 5000 (from defaults)
```
### `withFallback` vs `Object.assign`
When should you use `withFallback` versus `Object.assign`? Each has different trade-offs:
#### **`Object.assign` - Static Merging**
```typescript
// Memory cost: creates new objects, copying all properties
const merged = Object.assign({}, user, team, defaults);
```
**Pros:**
- **Fast lookups** - Properties are directly accessible
- **Memory efficient for access** - No proxy overhead
- **Simple and familiar** - Standard JavaScript behavior
- **Serializable** - Can be JSON.stringify'd directly
**Cons:**
- **Memory cost on creation** - Copies all properties from all objects
- **No validation** - Invalid values override valid ones
- **Shallow merge** - Nested objects are completely overwritten
- **Static** - Values are fixed at merge time
#### **`withFallback` - Dynamic Fallback**
```typescript
// Runtime cost: proxy traversal, but no memory copying
const config = withFallback(user)(team)(defaults)();
```
**Pros:**
- **Memory efficient for creation** - No copying, just references
- **Validation support** - Skip invalid values with custom validators
- **Deep fallback** - Nested objects preserve properties from multiple sources
- **Dynamic** - Values can change in source objects
- **Writes update source** - Modifications go to the primary object
**Cons:**
- **Runtime cost on access** - Proxy traversal on every property access
- **More complex** - Additional cognitive overhead
- **Not serializable** - Proxy objects need special handling for JSON
#### **When to Use Which**
**Use `Object.assign` when:**
- Configuration is set once and accessed frequently
- You need maximum lookup performance
- Values are simple and don't need validation
- You want standard JavaScript behavior
- Memory usage during creation is not a concern
**Use `withFallback` when:**
- You need validation logic (non-empty strings, positive numbers, etc.)
- Working with nested configuration objects
- Source configurations might change after merging
- Memory efficiency during creation is important
- You want writes to update the primary object
**Example Comparison:**
```typescript
// Object.assign - overwrites entire nested objects
const assigned = Object.assign(
{},
{ database: { host: "localhost", ssl: false } },
{ database: { port: 5432 } } // ssl: false is lost!
);
// withFallback - preserves properties from both objects
const fallback = withFallback({ database: { host: "localhost", ssl: false } })
({ database: { port: 5432 } })();
// Result: { database: { host: "localhost", ssl: false, port: 5432 } }
```
<br />
## 🔄 Method Composition Patterns
Make With provides powerful composition primitives that allow methods with the same name to work together instead of simply overriding each other.
### The Unified `compose` Primitive
The `compose` function creates methods that can access and enhance previous methods with the same name. It automatically handles both regular and chainable methods, making composition seamless regardless of the method type.
```typescript
// Adding logging to existing methods
const api = makeLayered({ count: 0 })
(makeChainable({
increment: (s) => ({ count: s.count + 1 }),
decrement: (s) => ({ count: s.count - 1 })
}))
(compose({
increment: (s, prevIncrement) => {
console.log('Before increment:', s.count);
const result = prevIncrement(s);
console.log('After increment:', result.count);
return result;
},
decrement: (s, prevDecrement) => {
if (s.count <= 0) {
console.warn('Cannot decrement below zero');
return s;
}
return prevDecrement(s);
}
}))
();
```
### Automatic Chainable Handling
The `compose` primitive automatically detects chainable methods and extracts the underlying state:
```typescript
// Composing chainable methods with validation and logging
const api = makeLayered({ count: 0, max: 100 })
(makeChainable({
increment: (s) => ({ ...s, count: s.count + 1 }),
add: (s, amount) => ({ ...s, count: s.count + amount }),
reset: (s) => ({ ...s, count: 0 })
}))
(compose({
increment: (s, prevIncrement) => {
if (s.count >= s.max) {
console.warn('Already at maximum value');
return s; // No change
}
console.log('Incrementing from', s.count);
return prevIncrement(s); // Returns state object automatically
},
add: (s, amount, prevAdd) => {
if (s.count + amount > s.max) {
console.warn(`Adding ${amount} would exceed maximum`);
return prevAdd(s, s.max - s.count); // Add only up to max
}
return prevAdd(s, amount);
}
}))
();
// All methods remain chainable:
const result = api.add(50).increment().add(20); // Logs warnings and enforces limits
```
**How it works:**
- `compose` automatically detects if previous methods return API instances or regular values
- For chainable methods: extracts the state object, so `prevMethod(s)` returns state
- For regular methods: returns the actual result value
- Your composed method doesn't need to know the difference!
### The `makeWithCompose` Auto-Composer
For simpler cases, `makeWithCompose` automatically composes methods when names overlap:
```typescript
// Validation pipeline with automatic composition
const userAPI = makeWithCompose({ users: [] })(
// Base save functionality
{ save: (s, user) => ({ users: [...s.users, user] }) },
// Add timestamp
{ save: (s, user, prevSave) => prevSave(s, { ...user, createdAt: Date.now() }) },
// Add validation
{ save: (s, user, prevSave) => {
if (!user.email) throw new Error('Email is required');
if (!user.name) throw new Error('Name is required');
return prevSave(s, user);
}
},
// Add ID generation
{ save: (s, user, prevSave) => prevSave(s, { ...user, id: crypto.randomUUID() }) }
);
// All validations and transformations happen automatically
const result = userAPI.save({ name: 'Alice', email: 'alice@example.com' });
```
### Common Composition Patterns
#### 1. Before/After Hooks
```typescript
const withLogging = compose({
save: (s, data, prevSave) => {
console.log('Starting save operation...');
const result = prevSave(s, data);
console.log('Save completed successfully');
return result;
}
});
```
#### 2. Error Handling Wrapper
```typescript
const withErrorHandling = compose({
process: (s, input, prevProcess) => {
try {
return prevProcess(s, input);
} catch (error) {
console.error('Processing failed:', error);
return s; // Fallback to current state
}
}
});
```
#### 3. Caching Layer
```typescript
const withCaching = compose({
expensiveOperation: (s, key, prevOperation) => {
if (s.cache && s.cache[key]) {
return s.cache[key];
}
const result = prevOperation(s, key);
return {
...result,
cache: { ...s.cache, [key]: result }
};
}
});
```
#### 4. Validation Pipeline
```typescript
const withValidation = compose({
update: (s, data, prevUpdate) => {
// Pre-validation
if (!data || typeof data !== 'object') {
throw new Error('Invalid data: must be an object');
}
const result = prevUpdate(s, data);
// Post-validation
if (!result.isValid) {
throw new Error('Update resulted in invalid state');
}
return result;
}
});
```
### Composition vs Inheritance
Traditional OOP uses inheritance hierarchies that can become brittle:
```typescript
// ❌ Inheritance approach - rigid and hard to test
class BaseUser extends User {
save() { /* base logic */ }
}
class ValidatedUser extends BaseUser {
save() {
this.validate();
super.save(); // Tightly coupled to parent
}
}
class LoggedUser extends ValidatedUser {
save() {
this.log('saving...');
super.save(); // Long inheritance chain
this.log('saved');
}
}
```
```typescript
// ✅ Composition approach - flexible and testable
const userAPI = makeLayered({ users: [] })
({ save: (s, user) => ({ users: [...s.users, user] }) }) // Base
(compose({ save: (s, user, prev) => /* validation */ })) // Validation
(compose({ save: (s, user, prev) => /* logging */ })) // Logging
();
```
**Benefits of Composition:**
- **Flexible ordering** - Add layers in any order
- **Independent testing** - Test each layer separately
- **Runtime composition** - Add/remove behaviors dynamically
- **No inheritance chains** - Avoid brittle hierarchies
- **Type safety** - Full TypeScript inference throughout
<br />
## 🏷️ TypeScript Guide
Make With is built from the ground up with TypeScript, providing excellent type safety and inference. This section covers the type system, generics, and best practices.
### Core Type Concepts
#### 1. The `Methods<S>` Type
The foundation type representing a collection of methods that operate on a subject:
```typescript
type Methods<S = any> = Record<string, (subject: S, ...args: any[]) => any>;
// Example usage:
interface UserState {
name: string;
age: number;
}
const userMethods: Methods<UserState> = {
getName: (state) => state.name,
setAge: (state, age: number) => ({ ...state, age }),
isAdult: (state) => state.age >= 18
};
```
#### 2. The `ChainableApi<Fns, S>` Type
The sophisticated return type that correctly infers chainable vs regular methods:
```typescript
type ChainableApi<Fns extends Methods<S>, S> = {
[K in keyof Omit<Fns, typeof IS_CHAINABLE>]: Fns[K] extends (
s: S,
...args: infer A
) => S
? (...args: A) => ChainableApi<Fns, S> // Chainable methods return new API
: Fns[K] extends (s: S, ...args: infer A) => infer R
? (...args: A) => R // Regular methods return their result
: never;
};
```
#### 3. Layer Function Types
For advanced `makeLayered` composition:
```typescript
type LayerFunction<CurrentApi extends object> =
(currentApi: CurrentApi) => Methods<CurrentApi>;
// Example:
const addLogging: LayerFunction<{ save: () => void }> = (api) => ({
saveWithLog: (state) => {
console.log('Saving...');
api.save();
console.log('Saved!');
}
});
```
### Type Inference and Safety
#### 1. Automatic Return Type Inference
TypeScript automatically infers the correct API shape:
```typescript
const counter = makeWith({ count: 0 })({
...makeChainable({
increment: (s) => ({ count: s.count + 1 }), // Returns new API
add: (s, n: number) => ({ count: s.count + n }) // Returns new API
}),
get: (s) => s.count, // Returns number
isEven: (s) => s.count % 2 === 0 // Returns boolean
});
// TypeScript knows the exact types:
const newCounter = counter.increment(); // Type: ChainableApi<...>
const count: number = counter.get(); // Type: number
const even: boolean = counter.isEven(); // Type: boolean
```
#### 2. Parameter Type Safety
Method parameters are strictly typed:
```typescript
interface TodoState {
items: Array<{ id: string; text: string; done: boolean }>;
}
const todoAPI = makeWith({ items: [] } as TodoState)({
...makeChainable({
addTodo: (state, text: string) => ({
items: [...state.items, { id: crypto.randomUUID(), text, done: false }]
}),
toggleTodo: (state, id: string) => ({
items: state.items.map(item =>
item.id === id ? { ...item, done: !item.done } : item
)
})
}),
getTodo: (state, id: string) => state.items.find(item => item.id === id),
getTodoCount: (state) => state.items.length
});
// TypeScript enforces parameter types:
todoAPI.addTodo("Buy milk"); // ✅ string parameter
todoAPI.toggleTodo("some-id"); // ✅ string parameter
// todoAPI.addTodo(123); // ❌ TS Error: number not assignable to string
```
#### 3. Layered API Type Building
`makeLayered` progressively builds type information:
```typescript
const api = makeLayered({ value: 0 })
// Layer 1: Base methods
(makeChainable({
add: (s, n: number) => ({ value: s.value + n }),
multiply: (s, n: number) => ({ value: s.value * n })
}))
// Layer 2: Getters (TypeScript knows about add/multiply)
({
get: (s) => s.value,
getDouble: (s) => s.value * 2
})
// Layer 3: Orchestration (TypeScript knows about all previous methods)
((currentApi) => ({
addThenDouble: (s, n: number) => {
const withAdded = currentApi.add(n); // TS knows this returns new API
return withAdded.getDouble(); // TS knows this returns number
}
}))
();
// TypeScript knows the complete API shape
const result: number = api.addThenDouble(5); // Type: number
```
### Common TypeScript Gotchas
#### 1. Losing Type Information with `any`
```typescript
// ❌ Using any loses all type safety
const badAPI = makeWith({ count: 0 } as any)({
increment: (s: any) => ({ count: s.count + 1 }) // No type checking!
});
// ✅ Use proper interfaces
interface CounterState {
count: number;
}
const goodAPI = makeWith({ count: 0 } as CounterState)({
increment: (s) => ({ count: s.count + 1 }) // Fully typed!
});
```
#### 2. Method Signature Mismatches
```typescript
interface State {
items: string[];
}
// ❌ Wrong: method doesn't match expected signature
const badMethods = {
// Should be (subject: State, item: string) => newState
addItem: (item: string) => item // Missing subject parameter!
};
// ✅ Correct: proper method signature
const goodMethods: Methods<State> = {
addItem: (state, item: string) => ({ items: [...state.items, item] })
};
```
#### 3. Chainable vs Non-Chainable Return Types
```typescript
const api = makeWith({ value: 0 })({
...makeChainable({
// ❌ Chainable method not returning new state
badIncrement: (s) => {
console.log('incrementing');
// Missing return! TypeScript will catch this.
},
// ✅ Chainable method returning new state
goodIncrement: (s) => ({ value: s.value + 1 })
}),
// ❌ Non-chainable method trying to be chainable
getValue: (s) => ({ value: s.value }), // Should just return s.value
// ✅ Non-chainable method
get: (s) => s.value
});
```
#### 4. Generic Type Constraints
```typescript
// When creating reusable functions, use proper constraints
function createCounter<T extends { count: number }>(initialState: T) {
return makeWith(initialState)({
...makeChainable({
increment: (s) => ({ ...s, count: s.count + 1 }),
add: (s, n: number) => ({ ...s, count: s.count + n })
}),
get: (s) => s.count
});
}
// Works with any object that has a count property
const simpleCounter = createCounter({ count: 0 });
const complexCounter = createCounter({ count: 0, name: "My Counter", active: true });
```
### Advanced Type Patterns
#### 1. Conditional API Building
```typescript
function createUserAPI<T extends { isAdmin?: boolean }>(user: T) {
const baseAPI = makeLayered(user)
({ getName: (u) => u.name })
({ getRole: (u) => u.isAdmin ? 'admin' : 'user' });
// Conditionally add admin methods
if (user.isAdmin) {
return baseAPI
({ deleteUser: (u, id: string) => `Admin ${u.name} deleting user ${id}` })
();
}
return baseAPI();
}
const adminAPI = createUserAPI({ name: 'Alice', isAdmin: true });
// TypeScript knows adminAPI has deleteUser method
const userAPI = createUserAPI({ name: 'Bob', isAdmin: false });
// TypeScript knows userAPI does NOT have deleteUser method
```
#### 2. Type-Safe Factory Composition
```typescript
interface UserBase {
name: string;
id: string;
}
interface UserWithPermissions extends UserBase {
permissions: string[];
}
const createUser = (name: string): UserBase => ({
name,
id: crypto.randomUUID()
});
const addPermissions = (user: UserBase): Pick<UserWithPermissions, 'permissions'> => ({
permissions: user.name.includes('admin') ? ['read', 'write', 'delete'] : ['read']
});
// TypeScript correctly infers the merged type
const createFullUser = enrich(createUser, addPermissions);
const user = createFullUser('admin-alice'); // Type: UserBase & { permissions: string[] }
```
### Best Practices for Type Safety
1. **Always use interfaces for state objects**
2. **Leverage TypeScript's inference instead of explicit typing**
3. **Use `as const` for literal types when needed**
4. **Prefer composition over complex inheritance hierarchies**
5. **Use proper generic constraints for reusable functions**
<br />
## 🚨 Troubleshooting & Error Reference
Make With includes comprehensive error handling with descriptive messages to help you identify and fix issues quickly. All errors are instances of the custom `LayeredError` class.
### Common Error Scenarios
#### 1. Invalid Subjects (State Objects)
**Error:** `[makeWith] Subject cannot be null or undefined`
```typescript
// ❌ This will throw
makeWith(null)({ get: (s) => s });
makeWith(undefined)({ get: (s) => s });
// ✅ Use valid objects
makeWith({})({ get: (s) => s });
makeWith({ value: 0 })({ get: (s) => s.value });
```
**Error:** `[makeWith] Subject must be an object, got string`
```typescript
// ❌ Primitives are not allowed
makeWith("hello")({ length: (s) => s.length });
// ✅ Wrap primitives in objects
makeWith({ value: "hello" })({ length: (s) => s.value.length });
```
#### 2. Function Validation Errors
**Error:** `[make] Argument at index 1 must be a function, got string`
```typescript
// ❌ All arguments must be functions
make(validFunction, "not a function", anotherFunction);
// ✅ Only pass functions
make(validFunction, anotherFunction);
```
**Error:** `[make] Function at index 0 must have a non-empty name`
```typescript
// ❌ Anonymous functions need names for the API
make(() => "hello");
// ✅ Use named functions or provide as object
make(function hello() { return "hello"; });
// OR
make({ hello: () => "hello" });
```
**Error:** `[make] Duplicate function name "save" found`
```typescript
// ❌ Function names must be unique
function save() { /* version 1 */ }
function save() { /* version 2 */ }
make(save, save);
// ✅ Use different names or pass as object to override
make({ save: () => "version 2" });
```
#### 3. Chainable Method Return Value Errors
**Error:** `[makeWith] Chainable method "increment" returned undefined`
```typescript
const counter = makeWith({ count: 0 })({
...makeChainable({
// ❌ Chainable methods must return new state
increment: (s) => { s.count++; } // returns undefined
})
});
// ✅ Return new state object
increment: (s) => ({ count: s.count + 1 })
```
**Error:** `[makeWith] Chainable method "reset" returned string. Chainable methods must return a new state object`
```typescript
// ❌ Chainable methods must return objects
reset: (s) => "reset complete"
// ✅ Return object state
reset: (s) => ({ count: 0 })
```
#### 4. Layer Function Errors
**Error:** `[makeLayered] Layer function must accept exactly one parameter, got function with 2 parameters`
```typescript
// ❌ Layer functions must accept exactly one parameter (the current API)
makeLayered({ value: 0 })
({ add: (s, n) => ({ value: s.value + n }) })
((api, extraParam) => ({ double: (s) => api.add(api.value) })) // Wrong!
();
// ✅ Layer functions take only the current API
((api) => ({ double: (s) => api.add(api.value) }))
```
**Error:** `[makeLayered] Layer function must return an object of methods, got undefined`
```typescript
// ❌ Layer functions must return method objects
((api) => {
console.log("Setting up layer...");
// Missing return statement!
})
// ✅ Always return methods object
((api) => ({
logAndDouble: (s) => {
console.log("Doubling...");
return api.add(api.value);
}
}))
```
#### 5. Factory Composition Errors (`enrich`)
**Error:** `[enrich] Primary factory must return an object, got string`
```typescript
// ❌ Both factories must return objects
const badPrimary = (name) => `Hello ${name}`; // returns string
const secondary = (obj) => ({ timestamp: Date.now() });
enrich(badPrimary, secondary);
// ✅ Return objects from both factories
const goodPrimary = (name) => ({ greeting: `Hello ${name}` });
```
#### 6. Composition Method Patterns
**Understanding what `prevMethod` returns:**
```typescript
// ✅ Correct: compose automatically handles both types
const api = makeLayered({ count: 0 })
(makeChainable({ increment: (s) => ({ count: s.count + 1 }) }))
({ get: (s) => s.count })
(compose({
increment: (s, prevIncrement) => {
const newState = prevIncrement(s); // ✅ Always returns state object
return { count: newState.count + 1 }; // Clear state transformation
},
get: (s, prevGet) => {
const value = prevGet(s); // ✅ Returns the actual number
console.log('Current count:', value);
return value;
}
}))
();
```
**Error:** `[compose] No previous method "methodName" found to compose with`
```typescript
// ❌ Trying to compose a method that doesn't exist in previous layers
const api = makeLayered({ data: [] })
({ save: (s, item) => ({ data: [...s.data, item] }) })
(compose({
delete: (s, id, prevDelete) => prevDelete(s, id) // ❌ No prevDelete method!
}))
();
// ✅ Only compose methods that exist in previous layers
const api = makeLayered({ data: [] })
({
save: (s, item) => ({ data: [...s.data, item] }),
delete: (s, id) => ({ data: s.data.filter(item => item.id !== id) })
})
(compose({
delete: (s, id, prevDelete) => {
console.log(`Deleting item ${id}`);
return prevDelete(s, id); // ✅ prevDelete exists
}
}))
();
```
### Debugging Tips
#### 1. Check Error Context
All errors include context about where they occurred:
```typescript
// Error message format: [context] specific error details
// Examples:
// [makeWith] Subject cannot be null or undefined
// [makeLayered] Layer 2 creation failed
// [enrich] Factory composition failed
```
#### 2. Validate Method Signatures
Ensure your methods follow the expected patterns:
```typescript
// Regular methods: (subject, ...args) => result
get: (state) => state.value,
add: (state, amount) => state.value + amount,
// Chainable methods: (subject, ...args) => newSubject
increment: (state) => ({ ...state, count: state.count + 1 }),
// Layer functions: (currentApi) => methods
(api) => ({
double: (state) => api.increment().increment()
})
```
#### 3. Use TypeScript for Better Error Prevention
TypeScript will catch many issues at compile time:
```typescript
// TypeScript will warn about return type mismatches
const counter = makeWith({ count: 0 })({
...makeChainable({
// TS Error: Type 'void' is not assignable to type '{ count: number }'
badIncrement: (s) => { s.count++; }
})
});
```
## ❓ FAQ
**Q: Is this a global state management library?**
**A:** No. `Make With` is designed for creating self-contained, encapsulated objects. It's perfect for local component state or module-level state, but it has no built-in concept of a global, application-wide store.
**Q: What about performance? Isn't creating new objects on every call slow?**
**A:** For the vast majority of use cases (UI state, SDKs), the performance impact is negligible. JavaScript engines are highly optimized for short-lived object creation. For performance-critical hot paths, you can use the mutable pattern shown in the advanced examples.
**Q: Why the empty `()` call at the end of `makeLayered`?**
**A:** This is the "terminator call." Because `makeLayered` allows a variable number of enhancement layers, it needs a clear signal that you are finished adding layers and want the final object to be constructed. The empty `()` provides an explicit and unambiguous way to finalize the process.
<br />
## 🤝 Contributing
Contributions, issues, and feature requests are welcome! Please feel free to submit a Pull Request or open an issue.
<br />
## 📄 License
This project is licensed under the MIT License.