npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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.

Permeator sequence diagram

Install

npm install @tsyche/membrane
# or
yarn add @tsyche/membrane
# or
pnpm add @tsyche/membrane

Quick 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 TBase as an explicit type argument (Membrane.object<User>(...)) does not work as a substitute — TypeScript has no partial type-argument inference, so TPermeate falls back to unknown and 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 array

Because 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 email above were an object instead of a string, mutating it through the projection's result would mutate the original aggregate too. Only ImmutablePermeator guards 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, so nullish() resolves a null/undefined base to Object.create(null) — the callback receives an empty object, not a scalar. If your callback reads base rather than ignoring it, handle a possibly-non-scalar base defensively, or avoid nullish bases with ScalarMembrane entirely.

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 copied

If 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
);
// => null

Permeator

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 did

Nested 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 touched

The 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 if save's second parameter happens to be a structurally compatible optional options object — and it will silently receive ambient as 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 destructuring

API 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