{"_id":"twinop","name":"twinop","dist-tags":{"latest":"1.0.0"},"versions":{"1.0.0":{"name":"twinop","version":"1.0.0","type":"module","description":"Derive parallel sync + async APIs from one shared generator body.","author":{"name":"Peter Placzek","email":"contact@tada5hi.net","url":"https://github.com/tada5hi"},"repository":{"type":"git","url":"git+https://github.com/tada5hi/twinop.git"},"main":"dist/index.mjs","types":"dist/index.d.mts","exports":{"./package.json":"./package.json",".":{"types":"./dist/index.d.mts","import":"./dist/index.mjs"}},"engines":{"node":">=22.0.0"},"scripts":{"build":"tsdown","test":"vitest --config test/vitest.config.ts --run","test:coverage":"vitest --config test/vitest.config.ts --run --coverage","lint":"eslint","lint:fix":"npm run lint -- --fix","prepare":"husky"},"keywords":["sync","async","generator","dual-api","typescript","esm"],"publishConfig":{"access":"public"},"license":"MIT","devDependencies":{"@tada5hi/commitlint-config":"^1.3.1","@tada5hi/eslint-config":"^2.3.0","@tada5hi/tsconfig":"^0.7.2","@vitest/coverage-v8":"^4.1.10","eslint":"^10.3.0","husky":"^9.1.7","tsdown":"^0.21.10","typescript":"^6.0.3","typescript-eslint":"^8.59.2","vitest":"^4.1.5"},"gitHead":"adb59f9b5c2ca37a705b014f17cbd4b83cb23ade","_id":"twinop@1.0.0","bugs":{"url":"https://github.com/tada5hi/twinop/issues"},"homepage":"https://github.com/tada5hi/twinop#readme","_nodeVersion":"24.15.0","_npmVersion":"11.12.1","dist":{"integrity":"sha512-CAq8nn9jgOnKqeh0iSSg0sz7YMv1+T7FjKsSh0+wwQfGs2+pnUsLl5TEbXkTyndlvbhWbxTQ9YhxMMy2YxJOWQ==","shasum":"f072a5fe072495374ef4e6bd00808123b2a7b84c","tarball":"https://registry.npmjs.org/twinop/-/twinop-1.0.0.tgz","fileCount":6,"unpackedSize":17827,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQCxkCjQdfeq5oPw3fJm9tSIaJChIcDJxYZg8h1Tb02c6AIgaPNX76OJoiK9RA6r1F6cMoARgFDtYiopj1Ow9nDBLTk="}]},"_npmUser":{"name":"tada5hi","email":"peter.placzek1996@gmail.com"},"directories":{},"maintainers":[{"name":"tada5hi","email":"peter.placzek1996@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/twinop_1.0.0_1785419876481_0.97677209426994"},"_hasShrinkwrap":false}},"time":{"created":"2026-07-30T13:57:49.889Z","1.0.0":"2026-07-30T13:57:56.657Z","modified":"2026-07-30T13:58:01.049Z"},"maintainers":[{"name":"tada5hi","email":"peter.placzek1996@gmail.com"}],"description":"Derive parallel sync + async APIs from one shared generator body.","homepage":"https://github.com/tada5hi/twinop#readme","keywords":["sync","async","generator","dual-api","typescript","esm"],"repository":{"type":"git","url":"git+https://github.com/tada5hi/twinop.git"},"author":{"name":"Peter Placzek","email":"contact@tada5hi.net","url":"https://github.com/tada5hi"},"bugs":{"url":"https://github.com/tada5hi/twinop/issues"},"license":"MIT","readme":"<p align=\"center\">\n    <img src=\"./assets/logo.svg\" alt=\"twinop\" width=\"128\" height=\"128\" />\n</p>\n\n<h1 align=\"center\">twinop</h1>\n\n<p align=\"center\">\n    <b>Derive parallel sync + async APIs from one shared generator body.</b><br>\n    Write the algorithm once as a generator that yields effect pairs; two tiny drivers<br>\n    execute the side each public function stands for, so <code>read()</code> and <code>readSync()</code> cannot drift.\n</p>\n\n<p align=\"center\">\n    <a href=\"https://npmjs.com/package/twinop\"><img src=\"https://badge.fury.io/js/twinop.svg\" alt=\"npm version\" /></a>\n    <a href=\"https://github.com/Tada5hi/twinop\"><img src=\"https://github.com/Tada5hi/twinop/workflows/CI/badge.svg\" alt=\"Master Workflow\" /></a>\n    <a href=\"https://codecov.io/gh/Tada5hi/twinop\"><img src=\"https://codecov.io/gh/Tada5hi/twinop/branch/master/graph/badge.svg\" alt=\"codecov\" /></a>\n    <a href=\"https://snyk.io/test/github/Tada5hi/twinop?targetFile=package.json\"><img src=\"https://snyk.io/test/github/Tada5hi/twinop/badge.svg?targetFile=package.json\" alt=\"Known Vulnerabilities\" /></a>\n    <a href=\"https://conventionalcommits.org\"><img src=\"https://img.shields.io/badge/Conventional%20Commits-1.0.0-%23FE5196?logo=conventionalcommits&logoColor=white\" alt=\"Conventional Commits\" /></a>\n</p>\n\n<p align=\"center\">\n    <a href=\"#installation\"><b>Installation</b></a>\n    ·\n    <a href=\"#usage\">Usage</a>\n    ·\n    <a href=\"#how-it-works\">How it works</a>\n    ·\n    <a href=\"#api\">API</a>\n    ·\n    <a href=\"#notes\">Notes</a>\n</p>\n\n---\n\n## Why twinop?\n\nSome libraries have to ship both `read()` and `readSync()`, `locate()` and `locateSync()`, `run()` and `runSync()`, because consumers like config loaders, CLIs and plugin systems frequently can't `await`. Writing that pair by hand means maintaining two copies of everything *around* the I/O: cache bookkeeping, error mapping, option precedence, ordering. The copies drift, and when they do the two variants quietly disagree about what your library does.\n\n`twinop` removes the second copy. You write the logic **once** as a generator that yields *effect pairs*, and two tiny drivers execute the side each public function stands for. One body cannot drift from itself.\n\n**Table of Contents**\n- [Installation](#installation)\n- [Usage](#usage)\n- [How it works](#how-it-works)\n- [API](#api)\n- [Notes](#notes)\n- [Contributing](#contributing)\n- [License](#license)\n\n## Installation\n\n```bash\nnpm install twinop --save\n```\n\n## Usage\n\nWrite the logic once as a **body**, a generator that performs each effect through `op(asyncThunk, syncThunk)`:\n\n```typescript\nimport { readFile } from 'node:fs/promises';\nimport { readFileSync } from 'node:fs';\nimport { op, runTwinAsync, runTwinSync, type TwinBody } from 'twinop';\n\nfunction* readJsonBody(path: string) : TwinBody<unknown> {\n    const content = yield* op(\n        () => readFile(path, 'utf-8'),\n        () => readFileSync(path, 'utf-8'),\n    );\n\n    try {\n        return JSON.parse(content);\n    } catch (e) {\n        // Runs identically in both variants, written once.\n        throw new Error(`${path} is not valid JSON`, { cause: e });\n    }\n}\n```\n\nThen expose the pair as two thin wrappers:\n\n```typescript\nexport function readJson(path: string) : Promise<unknown> {\n    return runTwinAsync(readJsonBody(path));\n}\n\nexport function readJsonSync(path: string) : unknown {\n    return runTwinSync(readJsonBody(path));\n}\n```\n\nThat is the whole pattern. Everything that is not I/O (the parse, the error message, any caching or option resolution you add later) exists exactly once.\n\n### Composition\n\nBodies compose with `yield*`, so a higher-level body can call a lower-level one without either knowing which side it will be driven on:\n\n```typescript\nfunction* readConfigBody(dir: string) : TwinBody<Config> {\n    const raw = yield* readJsonBody(`${dir}/config.json`);\n    return normalize(raw);\n}\n```\n\n### Errors behave the same on both sides\n\nAn effect failure is thrown back into the body at the `yield` site, so `try` / `catch` / `finally` inside a body reads and runs the same regardless of driver:\n\n```typescript\nfunction* body(path: string) : TwinBody<string> {\n    try {\n        return yield* op(\n            () => readFile(path, 'utf-8'),\n            () => readFileSync(path, 'utf-8'),\n        );\n    } catch {\n        return '';   // the fallback is written once, and applies to both variants\n    }\n}\n```\n\nAn error the body doesn't catch propagates out of the driver: as a rejected promise from `runTwinAsync`, as a throw from `runTwinSync`.\n\n### Asymmetric effects\n\nThe two thunks only have to *mean* the same thing, not be shaped alike. Anything the sync side genuinely cannot do belongs in the sync thunk:\n\n```typescript\nconst value = yield* op(\n    () => child.run(input),\n    () => {\n        if (typeof child.runSync !== 'function') {\n            throw new TypeError('nested unit does not support synchronous execution');\n        }\n        return child.runSync(input);\n    },\n);\n```\n\nThe check lives where it applies, and the async path can't accidentally trip over it.\n\n## How it works\n\nA body is a generator whose yielded values are `TwinOp`s, `{ async, sync }` thunk pairs. Each driver loops the generator, calls the thunk for its own side, and threads the result back in via `next(result)`. On failure it re-enters the body with `throw(error)`, which is what makes in-body `try`/`catch`/`finally` behave identically in both variants.\n\nAlways delegate with `yield*` rather than a bare `yield`: the delegation is what carries the effect's result type back to the call site.\n\n## API\n\n| Export | Kind | Purpose |\n|---|---|---|\n| `op(asyncFn, syncFn)` | generator fn | Perform one effect: `const x = yield* op(a, s)` |\n| `runTwinAsync(body)` | `Promise<R>` | Drive the async side |\n| `runTwinSync(body)` | `R` | Drive the sync side |\n| `TwinOp<T>` | type | `{ async: () => T \\| Promise<T>, sync: () => T }` |\n| `TwinBody<R>` | type | `Generator<TwinOp<any>, R, any>`, a body's return type |\n\n## Notes\n\n- **The async thunk may return a bare value.** `TwinOp.async` is `() => T | Promise<T>`, so an effect that is inherently synchronous (or a user-supplied callback that *might* be) can be handed to both slots unwrapped. `runTwinAsync` awaits either shape.\n- **`runTwinSync` creates no promise and queues no microtask.** If a sync thunk returns a thenable, the driver hands it to the body as-is; enforce your own policy there if a synchronous surface must reject it.\n- **Drive each body instance once.** A generator is single-use; call the body function again for a second run.\n- **Zero dependencies, ESM-only.**\n\n## Contributing\n\nBefore starting to work on a pull request, it is important to review the guidelines for\n[contributing](./CONTRIBUTING.md) and the [code of conduct](./CODE_OF_CONDUCT.md).\nThese guidelines will help to ensure that contributions are made effectively and are accepted.\n\n## License\n\nMade with 💚\n\nPublished under [MIT License](./LICENSE).\n","readmeFilename":"README.MD","_rev":"1-33fa8288180ecfcf552d6e6fb6e171f0"}