Factory function patterns to compose clients and services. Use when wrapping resources with domain methods or refactoring mixed client/service/method options.
67
81%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
This skill helps you apply factory function patterns for clean dependency injection and function composition in TypeScript.
Related Skills: See
method-shorthand-jsdocfor when to move helpers into the return object. Seerefactoringfor caller counting and inlining single-use extractions.
Every factory function follows this signature:
function createSomething(dependencies, options?) {
return {
/* methods */
};
}Two arguments max. First is resources, second is config. No exceptions.
// Single dependency
function createService(client, options = {}) {
return {
method(methodOptions) {
// Uses client, options, and methodOptions
},
};
}
// Multiple dependencies
function createService({ db, cache }, options = {}) {
return {
method(methodOptions) {
// Uses db, cache, options, and methodOptions
},
};
}
// Usage
const client = createClient(clientOptions);
const service = createService(client, serviceOptions);
service.method(methodOptions);// Bad
function doSomething(client, options) { ... }
doSomething(client, options);
// Good
const service = createService(client);
service.doSomething(options);// Bad
function doSomething(clientOptions, methodOptions) {
const client = createClient(clientOptions); // Hidden!
// ...
}
// Good
const client = createClient(clientOptions);
const service = createService(client);
service.doSomething(methodOptions);// Bad
doSomething({
timeout: 5000, // Client option
retries: 3, // Client option
endpoint: '/users', // Method option
payload: data, // Method option
});
// Good
const client = createClient({ timeout: 5000, retries: 3 });
const service = createService(client);
service.doSomething({ endpoint: '/users', payload: data });// Bad
function doSomething(clientOptions, serviceOptions, methodOptions) {
const client = createClient(clientOptions);
const service = createService(client, serviceOptions);
return service.method(methodOptions);
}
// Good: each layer visible and configurable
const client = createClient(clientOptions);
const service = createService(client, serviceOptions);
service.method(methodOptions);When your service needs multiple clients:
function createService(
{ db, cache, http }, // Dependencies as destructured object
options = {}, // Service options
) {
return {
method(methodOptions) {
// Uses db, cache, http
},
};
}
// Usage
const db = createDbConnection(dbOptions);
const cache = createCacheClient(cacheOptions);
const http = createHttpClient(httpOptions);
const service = createService({ db, cache, http }, serviceOptions);
service.method(methodOptions);The previous sections cover the external signature: (deps, options?) → return { methods }. This section covers what goes inside the function body. Every factory function follows a four-zone ordering:
// Option A: destructure in the signature (preferred for small dep lists)
function createSomething({ db, cache }: Deps, options?) {
const maxRetries = options?.maxRetries ?? 3;
// ...
}
// Option B: destructure in zone 1 (fine when you also need the deps object itself)
function createSomething(deps: Deps, options?) {
const { db, cache } = deps;
const maxRetries = options?.maxRetries ?? 3;
// ...
}Both are valid. The point is that by the time you reach zone 2, all dependencies and config are bound to const names. The four zones:
function createSomething({ db, cache }, options?) {
// Zone 1: Immutable state (const from deps/options)
const maxRetries = options?.maxRetries ?? 3;
// Zone 2: Mutable state (let declarations)
let connectionCount = 0;
let lastError: Error | null = null;
// Zone 3: Private helpers
function resetState() {
connectionCount = 0;
lastError = null;
}
// Zone 4: Public API (always last)
return {
connect() { ... },
disconnect() { ... },
get errorCount() { return connectionCount; },
};
}Zones 1 and 2 can merge when there's little state. Zone 3 is empty for small factories. But the return object is always last: it's the complete public API.
When the exported type is just the handle returned by one factory, derive the type from the factory instead of annotating the factory with the type.
export type RemoteClient = ReturnType<typeof createRemoteClient>;
export function createRemoteClient(options: RemoteClientOptions) {
return {
actions<T>(peerId: string): RemoteActionProxy<T> {
// ...
},
describe(peerId: string): Promise<ActionManifest> {
// ...
},
};
}Zone 4 is already the public API. Duplicating it in a manual return type creates a second source of truth and changes editor navigation: Go to Definition tends to jump to the alias instead of the returned member. Keep method parameter and return annotations inside zone 4 when they make the public surface clearer.
This is one face of a broader principle: when organizing types and exports, always consider Go-to-Definition. Adapter / proxy / wrapper factories with no behavior change are another regression in the same family: Go-to-Def lands on the wrapper instead of the source of truth. The "collapsed adapter" rule below is its concrete remedy. See typescript "Go-to-Definition Awareness" for the full set of regressions to watch for, and method-shorthand-jsdoc for the JSDoc sibling of this navigation concern.
Do not use this for shared service contracts that several factories implement. Those contracts are vocabulary. Use satisfies at the return object when a factory needs to prove it matches an external contract while preserving the concrete returned shape.
this Decision RuleInside the return object, public methods sometimes need to call other public methods. Use this.method() for that; method shorthand gives proper this binding.
If a function is called both by return-object methods and by pre-return initialization logic, it belongs in zone 3 (private helpers). Call it directly by name; no this needed.
| Where the function lives | How to call it |
|---|---|
| Return object (zone 4) | this.method() from sibling methods |
| Private helper (zone 3) | Direct call by name: helperFn() |
| Both zones need it | Keep in zone 3, call by name everywhere |
See Closures Are Better Privacy Than Keywords for the full rationale and real codebase examples.
A factory's return object can be designed to structurally satisfy an external contract, so it can be passed directly to a platform-agnostic core without an adapter.
The canonical example is createPersistedState / createStorageState, whose return shape structurally satisfies the SessionStore contract that @epicenter/auth's createAuth consumes:
// The contract (in a platform-agnostic core):
type SessionStore = {
get(): T | null;
set(value: T | null): void;
watch(fn: (next: T | null) => void): () => void;
};
// The factory exposes BOTH a reactive accessor AND the contract methods:
function createPersistedState(opts) {
let value = $state(readFromStorage());
// ...
return {
get current() { return value }, // reactive read for templates
set current(v) { setAndPersist(v) },
get(): T { return value }, // contract: sync read
set: setAndPersist, // contract: fire-and-forget write
watch(fn) { /* ... */ }, // contract: change notification
};
}
// Consumer: pass the factory result directly, no adapter:
const session = createPersistedState({ key, schema, defaultValue });
const auth = createAuth({ baseURL, session });If you find yourself writing a fromX translator that only renames or re-projects fields, delete it and widen the factory's return shape instead. The adapter is pure ceremony; the factory already holds the state, so just expose both surfaces.
Signs an adapter should be collapsed:
.current vs .get(): same value, different API).Signs an adapter should stay:
watch only fires on external change).When in doubt: start without the adapter. Add one only when the seam actually earns its keep.
TypeScript's structural typing means the factory doesn't have to implements SessionStore or import the contract type. As long as the return shape matches, it's assignable. This keeps the factory package free of the consumer's dependencies: createPersistedState lives in @epicenter/svelte and has zero knowledge of @epicenter/auth.
Think of it as a chain where each link:
createClient(...) → createService(client, ...) → service.method(...)
↑ ↑ ↑
clientOptions serviceOptions methodOptionsSee the full articles for more details:
Load on demand:
cb12bcc
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.