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.
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
orself
), completely eliminating the confusion of thethis
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.
npm install @doeixd/make-with
This library provides composable primitives that build on each other. This journey shows how they work together to create a full-featured API.
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.
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.
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.
import { collectFns } from '@doeixd/make-with';
function myCoolFunction() {}
const myFns = collectFns(myCoolFunction); // -> { myCoolFunction: [Function: myCoolFunction] }
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.
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');
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.
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)
Make With
is designed around the idea that your logic should be composed of simple, portable functions, not confined within rigid structures.
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:
// 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.
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:
// 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.
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.
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.
For the most complex scenarios, makeLayered
gives you ultimate control. It builds an API in distinct, "self-aware" layers.
makeLayered
supports two types of layers:
Method Objects - Direct method definitions:
{ methodName: (subject, ...args) => result }
Layer Functions - Functions that receive the current API and return methods:
(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
The double
method here orchestrates calls to get
and add
from previous layers, using self
to refer to the API instance being built.
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
Layer Functions provide more flexibility by receiving the current API as a parameter:
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
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.
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.
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.
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'] }
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.
For a comprehensive collection of examples demonstrating every feature of the library, check out the examples.ts file in the root of this project. It includes real-world patterns, custom helpers, and performance optimization techniques.
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. |
Named Imports (Recommended):
import {
makeWith,
makeChainable,
makeLayered,
compose,
merge,
createMerger,
createProxy,
createLens,
withFallback
} from '@doeixd/make-with';
Default Import (All functions):
import makeWithLib from '@doeixd/make-with';
const api = makeWithLib.makeLayered(state)
(makeWithLib.makeChainable(methods))
(makeWithLib.compose(enhancedMethods))
();
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:
const [getUser] = provide({ token: 'abc' })(
(cfg, username: string) => `Fetching ${username}...`
);
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:
function greet(name: string) { /* ... */ }
const api = collectFns(greet); // { greet: [Function: greet] }
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:
const api = provideTo({ count: 0 })({
get: (s) => s.count
});
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:
const counter = provideTo({ count: 0 })({
...makeChainable({
increment: (s) => ({ count: s.count + 1 }),
}),
});
const newCounter = counter.increment(); // Chainable!
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):
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:
// 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:
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));
};
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:
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"] } }
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:
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: "[email protected]" });
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
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:
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("[email protected]");
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:
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);
}
}
);
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:
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):
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!
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:
const api = makeLayered({ value: 10 })
(makeChainable({ add: (s, n) => ({ value: s.value + n }) }))
({ double: (self) => self.add(self.value) })
();
Layer Function Example:
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.
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:
const createUser = (name: string) => ({ name, id: 1 });
const addStatus = (user: { id: number }) => ({ status: 'active' });
const createFullUser = enrich(createUser, addStatus);
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:
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)
When should you use withFallback
versus Object.assign
? Each has different trade-offs:
// 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
// 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
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:
// 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 } }
Make With provides powerful composition primitives that allow methods with the same name to work together instead of simply overriding each other.
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.
// 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);
}
}))
();
The compose
primitive automatically detects chainable methods and extracts the underlying state:
// 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!
For simpler cases, makeWithCompose
automatically composes methods when names overlap:
// 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: '[email protected]' });
const withLogging = compose({
save: (s, data, prevSave) => {
console.log('Starting save operation...');
const result = prevSave(s, data);
console.log('Save completed successfully');
return result;
}
});
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
}
}
});
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 }
};
}
});
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;
}
});
Traditional OOP uses inheritance hierarchies that can become brittle:
// ❌ 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');
}
}
// ✅ 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
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.
The foundation type representing a collection of methods that operate on a subject:
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
};
The sophisticated return type that correctly infers chainable vs regular methods:
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;
};
For advanced makeLayered
composition:
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!');
}
});
TypeScript automatically infers the correct API shape:
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
Method parameters are strictly typed:
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
makeLayered
progressively builds type information:
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
// ❌ 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!
});
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] })
};
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
});
// 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 });
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
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[] }
- Always use interfaces for state objects
- Leverage TypeScript's inference instead of explicit typing
- Use
as const
for literal types when needed - Prefer composition over complex inheritance hierarchies
- Use proper generic constraints for reusable functions
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.
Error: [makeWith] Subject cannot be null or undefined
// ❌ 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
// ❌ Primitives are not allowed
makeWith("hello")({ length: (s) => s.length });
// ✅ Wrap primitives in objects
makeWith({ value: "hello" })({ length: (s) => s.value.length });
Error: [make] Argument at index 1 must be a function, got string
// ❌ 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
// ❌ 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
// ❌ 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" });
Error: [makeWith] Chainable method "increment" returned undefined
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
// ❌ Chainable methods must return objects
reset: (s) => "reset complete"
// ✅ Return object state
reset: (s) => ({ count: 0 })
Error: [makeLayered] Layer function must accept exactly one parameter, got function with 2 parameters
// ❌ 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
// ❌ 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);
}
}))
Error: [enrich] Primary factory must return an object, got string
// ❌ 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}` });
Understanding what prevMethod
returns:
// ✅ 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
// ❌ 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
}
}))
();
All errors include context about where they occurred:
// Error message format: [context] specific error details
// Examples:
// [makeWith] Subject cannot be null or undefined
// [makeLayered] Layer 2 creation failed
// [enrich] Factory composition failed
Ensure your methods follow the expected patterns:
// 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()
})
TypeScript will catch many issues at compile time:
// 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++; }
})
});
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.
Contributions, issues, and feature requests are welcome! Please feel free to submit a Pull Request or open an issue.
This project is licensed under the MIT License.