@tsyche/membrane
v0.8.1
Published
Type-safe async before/after pipelines for TypeScript service boundaries — input enrichment, output mapping, request context, and enforced immutability. Zero dependencies.
Maintainers
Readme
@tsyche/membrane
Type-safe async before/after pipelines for TypeScript service boundaries — input enrichment, output mapping, request context, and enforced immutability. Zero dependencies.
A Permeator wraps your domain logic in a three-step
pipeline: an input step enriches what your callback
receives, your callback does the actual work, and an
output step shapes what comes back — the same before/after
shape as a NestJS interceptor or Express middleware, but
with the input and output types actually flowing through
instead of collapsing to any. Request-scoped context
(tenant, correlation ID, whatever) threads into every step
as an explicit typed parameter, without getting spliced
into the data itself. An immutable variant clones and
freezes the input before your callback ever sees it, so
"the callback didn't mutate the original" is an enforced
guarantee rather than a code-review convention.
Each of these steps is a membrane — a small, typed unit
that resolves null/undefined to an appropriate empty
value and combines its callback's result with its input
according to one of a few explicit strategies (merge with
base winning, merge with the callback winning, or full
replacement, depending on the membrane — see
Membrane Types). Membranes compose into
sequences, and a Permeator orchestrates the full pipeline
around your callback.
Eight membrane types cover objects (merged or replaced), arrays, scalars, projections, proxies, streams, and sequenced chains. All are stateless and async-first.

Install
npm install @tsyche/membrane
# or
yarn add @tsyche/membrane
# or
pnpm add @tsyche/membraneQuick Start
import { Membrane, Permeator } from '@tsyche/membrane';
interface UserRequest {
userId: string;
}
// Create membranes for input enrichment and output post-processing.
// `base` must be annotated — TypeScript can only infer a membrane's
// base type from an explicitly-typed callback parameter. See
// "Type Inference" below for why.
const input = Membrane.object(async (base: UserRequest) => ({
...base,
timestamp: Date.now(),
}));
const output = Membrane.object(
async (base: { userId: string; timestamp: number; user: unknown }) => ({
...base,
processed: true,
}),
);
// Wire them into a permeator
const permeator = Permeator.mutable(input, output);
const result = await permeator.permeate({ userId: '123' }, async (scoped) => {
// scoped has { userId: '123', timestamp: ... }
const user = await fetchUser(scoped.userId);
return { ...scoped, user };
});
// result has { ...scoped, user, processed: true }Type Inference
TBase — a membrane's base type — is a real parameter
position, not something TypeScript can guess from context.
It is only inferable when the callback parameter is
explicitly annotated:
// Infers correctly: TBase = User
Membrane.object(async (base: User) => ({ ...base, role: 'admin' }));
// Does not infer: `base` has no annotation, and nothing else in the
// call supplies a type for it, so TBase falls back to `object`.
Membrane.object(async (base) => ({ ...base, role: 'admin' }));This isn't a workaround for a bug — it's a fundamental
limit of TypeScript's inference. A generic parameter that
appears only in a callback argument's own parameter list
(a contravariant position) can't be recovered from usage
inside the callback body; it has to come from an explicit
annotation on the parameter ((base: User) => ...). Every
example in this README annotates base for this reason.
Supplying
TBaseas an explicit type argument (Membrane.object<User>(...)) does not work as a substitute — TypeScript has no partial type-argument inference, soTPermeatefalls back tounknownand the result silently loses everything the callback added. Either annotate the parameter, or supply all the membrane's type arguments explicitly (Membrane.object<User, User & { role: string }>(...)) — partial instantiation is the trap, not explicit instantiation itself.
If you see an error like
Property 'userId' does not exist on type 'object & { timestamp: number; }',
that's this fallback happening — go annotate base on the
input membrane's callback (a different call site than
the error), not the one the error points at.
If you need one callback that works across several membrane
kinds (object, collection, scalar, ...), PermeateCallback
itself can't be that polymorphic — see
PassthroughPermeateCallback for a typed way to do
it with explicit type arguments at each construction site.
Concepts
- Base -- input data fed into a membrane
- Permeate -- callback-produced data merged with base
- Diffuse -- apply a membrane to base data
- Ambient -- per-call context (e.g. tenant, request metadata) threaded through the pipeline
When to use this (and when not to)
Why not a NestJS interceptor or pipe?
You don't have to choose — a Permeator composes fine inside
one. This library isn't an alternative request-lifecycle
hook; it's a typed body for the transform logic you'd
otherwise write loose inside an interceptor, where the
input/output shapes usually erode to any a few lines in.
Why not middy?
If you've hit this on an AWS Lambda handler, you've likely run into middy issue #914: "The current type signatures present in Middy core force middlewares to use the same event and result type before and after each middleware. This makes it impossible to write any properly typed middleware modules." That's the exact gap a Permeator closes — the input and output steps are independently typed, and the callback's scoped view is inferred from what the input step actually produces, not reconstructed from parts.
Why not class-transformer, Zod .transform(), or tRPC middleware?
class-transformer and Zod's .transform() are about
validating/coercing a single value's shape, not about
composing a before/after pipeline around a callback with
its own side effects. tRPC's middleware does solve a
similar problem — context extension with real inference —
but it's built into and scoped to tRPC procedures. Reach
for a Permeator when you want the same kind of typed
before/after/context composition somewhere that isn't a
tRPC router: a repository layer, a queue consumer, a plain
service method.
Why not just use AsyncLocalStorage for ambient context?
Honestly, for pure request-scoped context propagation, ALS
is less ceremony — it needs no plumbing, no threading
through function signatures, and works across any call
depth. Ambient here is deliberately the opposite: an
explicit, typed parameter every membrane and callback
declares. That costs you the boilerplate ALS avoids, and
buys you three things ALS doesn't: a type error instead of
an unpopulated AsyncLocalStorage.getStore() when a caller
forgets to provide it; context that's trivial to fake in a
unit test without touching global state; and no implicit
shared-mutable-state channel that has to be audited for
cross-request context bleed — some security/compliance
policies flag ALS-style storage for exactly that reason,
since it propagates data outside the function signature
rather than through it. If you don't need that trade, use
ALS.
When you shouldn't use this
A one-off transform with no reuse, no immutability requirement, and no ambient context doesn't need a membrane — just write the function. This library earns its keep when you have a repeated shape (several repository methods, several handlers) that all need the same kind of typed enrichment/mapping/context around them.
Membrane Types
Choosing an object membrane
Two independent questions decide which membrane to reach for: who wins on a shared key, and does a merge happen at all (do base-only keys survive into the result)?
| I want | Use |
| ------------------------------------------- | --------------------------------------------------------------------------------------------- |
| Base wins; callback adds fields | Membrane.object |
| Permeate wins; base fills the rest | Membrane.objectReplace, spreading base in the callback: async b => ({ ...b, ...changes }) |
| Result is exactly what the callback returns | Membrane.objectReplace |
| Base is an array, tuple, or class instance | Membrane.objectReplace (required — object silently empties it) |
| Only permeate's keys, base's values | Membrane.objectProjection |
ObjectMembrane
Merges base with callback-produced permeate data, base fields winning on conflict.
const membrane = Membrane.object(async (base: { name: string }) => ({
...base,
name: 'ignored',
extra: true,
}));
await membrane.diffuse({ name: 'Alice' });
// => { name: 'Alice', extra: true }The result carries base's prototype — instanceof and
accessors over public fields work — but Object.assign
cannot copy #private fields, so a method that touches one
throws TypeError. See
Object Results and Prototypes.
With an array-valued base this is a known limitation:
length is a non-enumerable own property, so Object.assign
never copies it, and the result silently behaves like an
empty array. Use ObjectReplaceMembrane for array/tuple
bases instead.
ObjectReplaceMembrane
The callback's return value replaces base wholesale, by
reference — no merge. Use this instead of Membrane.object
when base is an array/tuple or a class instance you need to
survive intact, or when the callback needs to drop fields
that a merge would otherwise preserve.
// The callback is fully responsible for the result — nothing from
// base is merged in automatically.
const membrane = Membrane.objectReplace(
async (base: { name: string; age: number }) => ({ name: base.name }),
);
await membrane.diffuse({ name: 'Alice', age: 30 });
// => { name: 'Alice' } — `age` is gone, not preserved// Because the callback's return value is used by reference, arrays,
// tuples, and class instances survive intact — a plain merge would
// silently break all three.
const membrane = Membrane.objectReplace(
async (base: [rows: { id: number }[], count: number]) => base,
);
const result = await membrane.diffuse([[{ id: 1 }, { id: 2 }], 2]);
const [rows, count] = result; // real destructuring — result is a real arrayBecause there's no merge, ObjectReplaceMembrane's result
type is exactly TPermeate — not TBase & TPermeate like
every merge-based membrane. An intersection would claim a
merge happened when it didn't.
CollectionMembrane
Handles arrays with index-based merge strategies. Supports
overwrite, preserve, and append.
// append (default): concatenates callback result onto base
const membrane = Membrane.collection(async () => [{ id: 3 }]);
await membrane.diffuse([{ id: 1 }, { id: 2 }]);
// => [{ id: 1 }, { id: 2 }, { id: 3 }]// overwrite: permeate wins at each index, base fills remaining
const membrane = Membrane.collection(async () => [{ id: 10 }], 'overwrite');
await membrane.diffuse([{ id: 1 }, { id: 2 }, { id: 3 }]);
// => [{ id: 10 }, { id: 2 }, { id: 3 }]// preserve: base wins at each index, permeate fills remaining
const membrane = Membrane.collection(
async () => [{ id: 10 }, { id: 20 }, { id: 30 }],
'preserve',
);
await membrane.diffuse([{ id: 1 }]);
// => [{ id: 1 }, { id: 20 }, { id: 30 }]ObjectProjectionMembrane
Subtractive membrane: merges base and permeate following
strategy, then returns only keys present in permeate.
Can subtract base keys and add new ones. Supports
overwrite and preserve strategies. Results are always
plain, prototype-less snapshots — see
Object Results and Prototypes.
// preserve (default): base values win on conflict,
// only permeate keys returned
//
// TBase must describe every field on the object you actually diffuse
// (id, name, email, password below), not just the fields the callback
// happens to read — TypeScript's excess-property checks reject passing
// a wider object literal than TBase declares.
const membrane = Membrane.objectProjection(
async (base: {
id: string;
name: string;
email: string;
password: string;
}) => ({
id: base.id,
email: base.email,
}),
);
await membrane.diffuse({
id: '1',
name: 'Alice',
email: '[email protected]',
password: 'hash',
});
// => { id: '1', email: '[email protected]' }// overwrite: permeate values win on conflict
const membrane = Membrane.objectProjection(
async (base: { id: string; name: string; email: string }) => ({
id: base.id,
email: base.email.toUpperCase(),
}),
'overwrite',
);
await membrane.diffuse({
id: '1',
name: 'Alice',
email: '[email protected]',
});
// => { id: '1', email: '[email protected]' }The projected fields are shallow references into base: if
ImmutablePermeatorguards against that (see Immutability).
ProxyMembrane
Wraps permeate in a Proxy that falls back to base for
missing properties. Permeate shadows base on conflict. The
Proxy traps property access (get/has) and enumeration
(ownKeys/getOwnPropertyDescriptor), so base's fields are
visible to spreading, JSON.stringify, Object.keys, and
Object.entries too — not just direct result.x access.
const membrane = Membrane.proxy(async (base: { name: string }) => ({
extra: true,
}));
const result = await membrane.diffuse({ name: 'Alice' });
result.name; // 'Alice' (from base)
result.extra; // true (from permeate)
'name' in result; // true
'extra' in result; // true
Object.keys(result); // ['extra', 'name'] — permeate's own keys first, then base's
JSON.stringify(result); // '{"extra":true,"name":"Alice"}'Writes land on the permeate side, shadowing base going
forward without mutating it: result.name = 'Bob' makes
result.name read 'Bob', but the original base object is
untouched.
ScalarMembrane
Handles primitives (string, number, boolean). Callback produces the full replacement value.
const membrane = Membrane.scalar(async (base: string) => base.toUpperCase());
await membrane.diffuse('hello');
// => 'HELLO'There's no type-appropriate empty value for an arbitrary
string | number | boolean, sonullish()resolves a null/undefined base toObject.create(null)— the callback receives an empty object, not a scalar. If your callback readsbaserather than ignoring it, handle a possibly-non-scalarbasedefensively, or avoid nullish bases withScalarMembraneentirely.
SequenceMembrane
Chains multiple membranes, piping each output as base to
the next. Each step's callback can be typed for the shape
the previous step actually produces — TypeScript's bivariant
method-parameter checking accepts a later step whose TBase
is wider than what it will actually receive. The sequence's
own static result type is fixed from the first step, so
later fields (like second below) type-check on diffuse's
runtime return value but aren't visible on its declared
type; a chain that needs those fields typed at each step
should compose membranes explicitly instead.
const first = Membrane.object(async (base: { name: string }) => ({
...base,
first: true,
}));
const second = Membrane.object(
async (base: { name: string; first: boolean }) => ({
...base,
second: true,
}),
);
const seq = Membrane.sequence(first, second);
await seq.diffuse({ name: 'test' });
// => { name: 'test', first: true, second: true }StreamMembrane
Processes each chunk of an AsyncIterable through the
callback individually. Supports overwrite and preserve
strategies on a per-chunk basis. Results are always plain,
prototype-less snapshots — see
Object Results and Prototypes.
// preserve (default): chunk properties win on conflict
const membrane = Membrane.stream(async (chunk: { id: number }) => ({
...chunk,
extra: true,
id: 999,
}));
async function* source() {
yield { id: 1 };
yield { id: 2 };
}
const output = await membrane.diffuse(source());
for await (const item of output) {
console.log(item);
}
// { id: 1, extra: true }
// { id: 2, extra: true }// overwrite: permeate wins on conflict
const membrane = Membrane.stream(
async (chunk: { id: number }) => ({ ...chunk, id: 999 }),
'overwrite',
);
// chunks { id: 1 }, { id: 2 } => { id: 999 }, { id: 999 }Object Results and Prototypes
The object-shaped membranes don't all give the same answer about what a result's prototype is — this is deliberate, not an oversight:
| Membrane | Result prototype |
| -------------------------- | ------------------------------------------------ |
| ObjectMembrane | base's prototype — see caveat below |
| ObjectReplaceMembrane | whatever the callback returned, by reference |
| ObjectProjectionMembrane | null (plain snapshot) |
| StreamMembrane | null per chunk (plain snapshot) |
| CollectionMembrane | items are never cloned — original references |
| ProxyMembrane | a Proxy; base is the fallback for missing keys |
ObjectMembrane carrying base's prototype means
instanceof and accessors over public fields work on the
result — but Object.assign cannot copy #private fields,
so a method that touches one throws:
class Account {
#balance = 100;
constructor(public readonly id: string) {}
reveal() {
return this.#balance; // touches #balance
}
}
const membrane = Membrane.object(async (base: Account) => ({ id: base.id }));
const result = await membrane.diffuse(new Account('1'));
result instanceof Account; // true
result.id; // '1' — public field, fine
result.reveal(); // throws TypeError — #balance wasn't copiedIf you need a real instance back — one where every method
works — map the result through your own constructor after
diffuse()/permeate(). If you're diffusing an array/tuple
base, use ObjectReplaceMembrane instead of ObjectMembrane
(see ObjectReplaceMembrane).
Nullish Resolution
Every membrane resolves null and undefined base values
transparently. When diffuse() receives a nullish base,
the membrane converts it to a type-appropriate empty value
before processing. This is especially useful when a
Permeator callback returns Entity | null (e.g. from a
findOne() call) and the output membrane needs to handle
the null case.
Each membrane type resolves to:
| Membrane | Nullish fallback |
| -------------------------- | ---------------------------------------- |
| ObjectMembrane | Object.create(null) ({}) |
| ObjectReplaceMembrane | Object.create(null) ({}) |
| CollectionMembrane | [] |
| ObjectProjectionMembrane | Object.create(null) ({}) |
| ProxyMembrane | Object.create(null) ({}) |
| ScalarMembrane | Object.create(null) — see caveat above |
| StreamMembrane | empty AsyncIterable |
| SequenceMembrane | delegates to first membrane |
// Output membrane handles null from findOne()
const output = Membrane.object(async (base: { loaded?: boolean }) => ({
...base,
loaded: true,
}));
// null base resolves to {} before the callback runs
await output.diffuse(null);
// => { loaded: true }You can also call nullish() directly to pre-resolve a
value without diffusing:
const membrane = Membrane.object(async (base: { id?: number }) => base);
membrane.nullish(null); // => {}
membrane.nullish(undefined); // => {}
membrane.nullish({ id: 1 }); // => { id: 1 }NullableMembrane
Wraps any membrane so that diffuse(null | undefined)
returns null when the callback does not augment the
resolved empty value. When the callback enriches the empty
value, the enriched result is returned normally. Non-null
base values always delegate to the inner membrane.
Use Membrane.nullable() to opt in. This is especially
useful as a Permeator output membrane when the callback may
return null (e.g. findOne()) and you want null to flow
through instead of being converted to {}.
The "did the callback augment it" check is a heuristic (empty array, zero own keys, etc.) — it can't distinguish "there was nothing to enrich" from "the enrichment legitimately produced an empty result" (e.g. a collection query that correctly returns zero rows). Reach for it when null-vs-empty genuinely don't need to be distinguished by your callers.
// Standalone usage
const membrane = Membrane.nullable(
Membrane.object(async (base: object) => base),
);
await membrane.diffuse(null);
// => null (callback did not augment)// Augmenting callback still returns enriched result
const membrane = Membrane.nullable(
Membrane.object(async (base: { loaded?: boolean }) => ({
...base,
loaded: true,
})),
);
await membrane.diffuse(null);
// => { loaded: true }// As Permeator output — null flows through from callback
const input = Membrane.object(async (base: { id: string }) => base);
const output = Membrane.nullable(Membrane.object(async (base: object) => base));
const permeator = Permeator.mutable(input, output);
const result = await permeator.permeate(
{ id: '1' },
async () => null, // e.g. findOne() returns null
);
// => nullPermeator
Orchestrates a three-step pipeline: input.diffuse(base) → callback(scoped, ambient) → output.diffuse(result). Ambient reaches every membrane's callback and your own domain callback.
Use Permeator.mutable() when the pipeline output replaces
the original base. Use Permeator.immutable() when the
pipeline runs for side-effects and the original base is
returned unchanged.
import { Membrane, Permeator } from '@tsyche/membrane';
const input = Membrane.object(async (base: { name: string }) => ({
...base,
enriched: true,
}));
const output = Membrane.object(
async (base: { name: string; enriched: boolean; fromDb: boolean }) => ({
...base,
postProcessed: true,
}),
);
const permeator = Permeator.mutable(input, output);
const result = await permeator.permeate({ name: 'input' }, async (scoped) => {
// scoped = { name: 'input', enriched: true }
return { ...scoped, fromDb: true };
});
// => { name: 'input', enriched: true, fromDb: true, postProcessed: true }Immutability
ImmutablePermeator runs the pipeline against a deep,
frozen clone of base — the callback never sees, let
alone touches, base itself. This is what makes "immutable"
an enforced guarantee rather than a naming convention:
nothing the callback does can reach the caller's object, and
the caller is free to keep using and mutating base normally
after permeate() returns — it was never touched.
Whether a write to scoped itself throws depends on which
input membrane you used, because merge-based membranes
(ObjectMembrane, ObjectProjectionMembrane,
StreamMembrane, CollectionMembrane) always build a fresh,
unfrozen object for the callback — Object.assign onto a new
target, same as it does outside ImmutablePermeator. A
top-level write to scoped through one of these succeeds,
but it was already harmless: it lands on the fresh merge
target, never on the frozen clone or base. Only membranes
that hand the callback an object by reference —
ObjectReplaceMembrane with a pass-through callback — expose
the frozen clone itself, so a top-level write throws a
TypeError from the freeze. (ScalarMembrane throws on a
top-level write too, but for a more basic reason: scoped is
typically a primitive, and strict-mode code can't assign a
property onto a primitive regardless of freezing.)
const input = Membrane.objectReplace(async (base: { total: number }) => base);
const output = Membrane.objectReplace(async (base: { total: number }) => base);
const permeator = Permeator.immutable(input, output);
const aggregate = { id: '1', total: 100 };
await permeator.permeate(aggregate, async (scoped) => {
scoped.total = 999; // throws TypeError — scoped IS the frozen clone
return scoped;
});
Object.isFrozen(aggregate); // false — base itself was never touched
aggregate.total = 200; // works fine, always didNested values are protected either way, regardless of which
membrane you use — a merge is only shallow, so a nested
object or array reached through scoped is the same frozen
node the clone produced, not a fresh copy:
const input = Membrane.object(
async (base: { nested: { flag: boolean } }) => base,
);
const output = Membrane.object(
async (base: { nested: { flag: boolean } }) => base,
);
const permeator = Permeator.immutable(input, output);
const aggregate = { id: '1', nested: { flag: false } };
await permeator.permeate(aggregate, async (scoped) => {
scoped.nested.flag = true; // throws TypeError — nested reaches the frozen clone
return scoped;
});
aggregate.nested.flag; // false — never touchedThe clone's scope is the same as the merge membranes': plain
objects, arrays, and null-prototype objects are cloned and
frozen; class instances, Date, Map, Set, and functions
reachable from base pass through by reference, unfrozen
— cloning them wouldn't preserve their behavior, and freezing
them wouldn't stop internal mutation anyway, since their state
usually lives behind accessor methods.
Error Handling
Pass an onError handler in the options to normalize
errors. It must throw. The handler also receives which
pipeline step failed ('input' | 'callback' | 'output') and
the ambient context the call was made with, so a bug in your
output membrane and a dropped DB connection in your domain
callback don't arrive at onError indistinguishable.
class DomainError extends Error {
constructor(
public readonly cause: unknown,
public readonly phase: string,
) {
super('Operation failed');
}
}
const permeator = Permeator.mutable(input, output, {
onError: (error, { phase, ambient }) => {
throw new DomainError(error, phase);
},
});
await permeator.permeate({ name: 'input' }, async () => {
throw new Error('db connection lost');
});
// throws DomainError { cause: Error('db connection lost'), phase: 'callback' }Advanced Examples
Domain Aggregate Protection
Enrich the scoped view with tenantId and correlationId
from ambient context. The callback persists the enriched
object, but the ImmutablePermeator returns the original
aggregate unchanged.
interface Aggregate {
id: string;
name: string;
total: number;
}
const input = Membrane.object(
async (
base: Aggregate,
ambient?: { tenantId?: string; correlationId?: string },
) => ({
...base,
tenantId: ambient?.tenantId,
correlationId: ambient?.correlationId,
}),
);
const output = Membrane.object(async (base: Aggregate) => base);
// immutable returns original base from permeate()
const permeator = Permeator.immutable(input, output);
// domain aggregate (business data)
const aggregate: Aggregate = { id: '1', name: 'Alice', total: 100 };
const result = await permeator.permeate(
aggregate,
async (scoped) => {
// scoped = { id: '1', name: 'Alice', total: 100,
// tenantId: 'acme', correlationId: '...' }
await repo.save(scoped);
return scoped;
},
{ tenantId: 'acme', correlationId: crypto.randomUUID() },
);
// result === aggregate (no tenantId or correlationId)Sequence + Permeator (Multi-Step Enrichment)
Use a SequenceMembrane as the input membrane to run multiple enrichment steps before the callback.
const step1 = Membrane.object(async (base: { name: string }) => ({
...base,
validated: true,
}));
const step2 = Membrane.object(
async (base: { name: string; validated: boolean }) => ({
...base,
normalized: true,
}),
);
const input = Membrane.sequence(step1, step2);
const output = Membrane.object(
async (base: { name: string; saved: boolean }) => ({
...base,
logged: true,
}),
);
const permeator = Permeator.mutable(input, output);
const result = await permeator.permeate({ name: 'input' }, async (scoped) => {
// scoped = { name: 'input', validated: true, normalized: true }
return { ...scoped, saved: true };
});
// => { name: 'input', validated: true, normalized: true, saved: true, logged: true }Ambient Threading
Ambient context flows through every membrane in the pipeline and into your domain callback directly — no need to splice it into the data just to make it visible.
const input = Membrane.object(
async (base: { name: string }, ambient?: { tenant?: string }) => base,
);
const output = Membrane.object(async (base: { name: string }) => base);
const permeator = Permeator.mutable(input, output);
const result = await permeator.permeate(
{ name: 'input' },
async (scoped, ambient?: { tenant?: string }) => {
await repo.save(scoped, { tenant: ambient?.tenant });
return scoped;
},
{ tenant: 'acme' },
);
// => { name: 'input' } — `tenant` was available to the callback
// without becoming a field on the persisted data.Watch out for bound method references here:
permeator.permeate(base, repo.save.bind(repo))compiles ifsave's second parameter happens to be a structurally compatible optional options object — and it will silently receiveambientas that second argument, not whatever you intended. Wrap it instead:(entity) => repo.save(entity).
Mixed Membrane Types
Different membrane types can be combined freely. Here an ObjectMembrane processes the input while a CollectionMembrane handles the output.
const input = Membrane.object(async (base: { take: number }) => base);
const output = Membrane.collection(
async (base: { id: number }[]) => base,
'overwrite',
);
const permeator = Permeator.mutable(input, output);
const result = await permeator.permeate({ take: 10 }, async () => [
{ id: 1 },
{ id: 2 },
]);
// => [{ id: 1 }, { id: 2 }]Recipes
Multi-tenant repository wrapper
tenantId scopes the write but never becomes a field on
the persisted entity.
interface Order {
id: string;
total: number;
}
interface Ctx {
tenantId: string;
[key: string]: unknown;
}
const input = Membrane.objectReplace<Partial<Order>, Partial<Order>, Ctx>(
async (base) => base,
);
const output = Membrane.objectReplace<Order, Order, Ctx>(async (base) => base);
const permeator = Permeator.mutable(input, output);
async function createOrder(data: Partial<Order>, tenantId: string) {
return permeator.permeate(
data,
async (scoped, ambient) => {
// ambient!.tenantId scopes the write; it's never part of `scoped`
// and never assigned onto the row this function returns.
return repo.insert('orders', scoped, { tenantId: ambient!.tenantId });
},
{ tenantId },
);
}
// createOrder({ total: 42 }, 'acme') inserts scoped by tenant,
// returns an Order with no tenantId field.API response DTO stripping internal fields
interface UserRow {
id: string;
email: string;
password: string;
internalNotes: string;
}
const toResponse = Membrane.objectProjection(async (base: UserRow) => ({
id: base.id,
email: base.email,
}));
await toResponse.diffuse(userRow);
// => { id: '...', email: '...' } — password and internalNotes
// never leave this function.Audit logging without mutating the caller's aggregate
interface Invoice {
id: string;
status: string;
}
const input = Membrane.objectReplace<Invoice, Invoice>(async (base) => base);
const output = Membrane.objectReplace<Invoice, Invoice>(async (base) => base);
const auditPermeator = Permeator.immutable(input, output);
async function markPaid(invoice: Invoice) {
return auditPermeator.permeate(invoice, async (scoped) => {
const updated = { ...scoped, status: 'paid' }; // scoped is frozen — copy first
await auditLog.record('invoice.paid', updated);
return updated;
});
// return value === invoice, unchanged — ImmutablePermeator always
// returns the original base, whatever the callback returns.
}Paginated [rows, count] tuple
The case ObjectReplaceMembrane exists for: a plain
merge-based membrane would silently turn this tuple into
{ 0: rows, 1: count } with length: 0 (see
Object Results and Prototypes).
interface Entity {
id: string;
}
const output = Membrane.objectReplace<[Entity[], number], [Entity[], number]>(
async (base) => base,
);
async function findAndCount(take: number): Promise<[Entity[], number]> {
const [rows, total] = await db.query('...', take); // a real tuple from your ORM
return output.diffuse([rows, total]);
}
const [rows, count] = await findAndCount(20); // real destructuringAPI Reference
Membrane Factory
| Method | Returns | Strategies | Default |
| ------------------------------------------------ | -------------------------- | --------------------------------------- | ------------ |
| Membrane.object(callback) | ObjectMembrane | | |
| Membrane.objectReplace(callback) | ObjectReplaceMembrane | | |
| Membrane.collection(callback, strategy?) | CollectionMembrane | 'overwrite' \| 'preserve' \| 'append' | 'append' |
| Membrane.objectProjection(callback, strategy?) | ObjectProjectionMembrane | 'overwrite' \| 'preserve' | 'preserve' |
| Membrane.sequence(first, ...rest) | SequenceMembrane | | |
| Membrane.proxy(callback) | ProxyMembrane | | |
| Membrane.scalar(callback) | ScalarMembrane | | |
| Membrane.stream(callback, strategy?) | StreamMembrane | 'overwrite' \| 'preserve' | 'preserve' |
| Membrane.nullable(membrane) | NullableMembrane | | |
Permeator Factory
| Method | Returns |
| ---------------------------------------------- | -------------------- |
| Permeator.mutable(input, output, options?) | MutablePermeator |
| Permeator.immutable(input, output, options?) | ImmutablePermeator |
Types
type PermeateCallback<
TBase = unknown,
TPermeate = unknown,
TAmbient extends PlainLiteralObject = PlainLiteralObject,
> = (base: TBase, ambient?: TAmbient) => Promise<TPermeate>;
// A reusable, membrane-kind-agnostic hook callback: one value assignable
// to `PermeateCallback<T, T, TAmbient>` for any `T` (object, array,
// tuple, scalar). Supply explicit type arguments at each construction
// site — see "Type Inference" above.
type PassthroughPermeateCallback<
TAmbient extends PlainLiteralObject = PlainLiteralObject,
> = <TBase>(base: TBase, ambient?: TAmbient) => Promise<TBase>;
type ProjectionMergeStrategy = 'overwrite' | 'preserve';
type CollectionMergeStrategy = 'overwrite' | 'preserve' | 'append';
type StreamMergeStrategy = 'overwrite' | 'preserve';
interface PermeatorOptions<
TAmbient extends PlainLiteralObject = PlainLiteralObject,
> {
onError?: MembraneErrorHandler<TAmbient>;
}
type PermeatePhase = 'input' | 'callback' | 'output';
interface MembraneErrorContext<
TAmbient extends PlainLiteralObject = PlainLiteralObject,
> {
phase: PermeatePhase;
ambient?: TAmbient;
}
type MembraneErrorHandler<
TAmbient extends PlainLiteralObject = PlainLiteralObject,
> = (error: unknown, context: MembraneErrorContext<TAmbient>) => never;
type PlainLiteralObject = Record<string, unknown>;Interfaces
interface IMembrane<
TBase,
TPermeate = unknown,
TAmbient extends PlainLiteralObject = PlainLiteralObject,
TResult = TBase & TPermeate,
> {
nullish(value: TBase | null | undefined): TBase;
diffuse(base: TBase | null | undefined, ambient?: TAmbient): Promise<TResult>;
}
interface IPermeator<
TInput,
TOutput,
TScoped = TInput,
TPermeateOut = unknown,
TAmbient extends PlainLiteralObject = PlainLiteralObject,
TResult = TOutput & TPermeateOut,
> {
permeate(
base: TInput,
callback: (
permeate: TScoped,
ambient?: TAmbient,
) => Promise<TOutput | null | undefined>,
ambient?: TAmbient,
): Promise<TResult>;
}TScoped — what the callback in permeate() receives — is
inferred from the input membrane's own diffuse() return
type, not reconstructed from separate pieces; wire an input
membrane whose base/permeate types are already what you want
the callback to see. ObjectReplaceMembrane overrides
TResult to TPermeate (no TBase & intersection) — see
ObjectReplaceMembrane for why.
License
BSD-3-Clause
