From 4ef12db64c8072e3dd9c51d9e2054868aa858983 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Sat, 18 Apr 2026 01:07:25 -0400 Subject: [PATCH] feat: add isTask() type guard for narrowing tasks to a moroutine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Moroutines returned by mo() expose a readonly .id - isTask(mo, task) narrows task to Task inferred from the moroutine's return type; useful when a pool handles tasks from multiple moroutines and you want to recover the specific shape - Task.args is now typed as A (previously unknown[]), so narrowing propagates through task.args — backward compatible for Task where A defaults to unknown[] Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/is-task.md | 16 +++++++++++++ src/index.ts | 1 + src/is-task.ts | 27 ++++++++++++++++++++++ src/mo.ts | 13 +++++++---- src/runner.ts | 5 +--- test/fixtures/is-task.ts | 13 +++++++++++ test/is-task.test.ts | 50 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 .changeset/is-task.md create mode 100644 src/is-task.ts create mode 100644 test/fixtures/is-task.ts create mode 100644 test/is-task.test.ts diff --git a/.changeset/is-task.md b/.changeset/is-task.md new file mode 100644 index 0000000..a6b22db --- /dev/null +++ b/.changeset/is-task.md @@ -0,0 +1,16 @@ +--- +'moroutine': minor +--- + +Add `isTask()` type guard for narrowing tasks to a specific moroutine + +```ts +if (isTask(isPrime, task)) { + // task: Task + const [n] = task.args; +} +``` + +- Moroutines returned by `mo()` now expose a readonly `id` for stable identity +- `isTask(mo, task)` returns `true` when `task` was produced by `mo`, and narrows the task to the descriptor type produced by that moroutine +- `Task.args` is now typed as `A` (previously `unknown[]`) so narrowing propagates to argument access — unchanged for `Task` without a specialized arg tuple diff --git a/src/index.ts b/src/index.ts index 9235293..33323ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export { workers } from './worker-pool.ts'; export { transfer } from './transfer.ts'; export { assign } from './assign.ts'; export { inert } from './inert.ts'; +export { isTask } from './is-task.ts'; export { map } from './map.ts'; export type { MapOptions } from './map.ts'; export { roundRobin, leastBusy } from './balancers.ts'; diff --git a/src/is-task.ts b/src/is-task.ts new file mode 100644 index 0000000..e0afee0 --- /dev/null +++ b/src/is-task.ts @@ -0,0 +1,27 @@ +import type { Task } from './runner.ts'; + +type TaskOf = M extends { (...args: any[]): infer R } + ? R extends Task + ? Task + : never + : never; + +/** + * Narrows a task to the descriptor type produced by a specific moroutine. + * + * ```ts + * if (isTask(isPrime, task)) { + * // task: Task + * } + * ``` + * + * @param moroutine - A function returned by {@link mo}. + * @param task - A task to test. + * @returns `true` if `task` was created by `moroutine`. + */ +export function isTask }>( + moroutine: M, + task: Task, +): task is TaskOf { + return task.id === moroutine.id; +} diff --git a/src/mo.ts b/src/mo.ts index 1067295..5253635 100644 --- a/src/mo.ts +++ b/src/mo.ts @@ -20,11 +20,15 @@ type TaskableArgs = { [K in keyof A]: Arg }; type Moroutine = { (...args: A): Task, A> & PromiseLike>; (...args: TaskableArgs): Task, A> & PromiseLike>; + /** Stable id tying tasks back to this moroutine. Used by {@link isTask}. */ + readonly id: string; }; type AsyncIterableMoroutine = { (...args: A): Task, A> & AsyncIterable; (...args: TaskableArgs): Task, A> & AsyncIterable; + /** Stable id tying tasks back to this moroutine. Used by {@link isTask}. */ + readonly id: string; }; type IsNever = [T] extends [never] ? true : false; @@ -55,8 +59,9 @@ export function mo(importMeta: ImportMeta, fn: (...args: registry.set(id, fn); - if (isAsyncGeneratorFunction(fn)) { - return ((...args: unknown[]) => new AsyncIterableTask(id, args)) as any; - } - return ((...args: unknown[]) => new PromiseLikeTask(id, args)) as any; + const factory = isAsyncGeneratorFunction(fn) + ? (...args: unknown[]) => new AsyncIterableTask(id, args) + : (...args: unknown[]) => new PromiseLikeTask(id, args); + Object.defineProperty(factory, 'id', { value: id, enumerable: true }); + return factory as unknown as MoReturn; } diff --git a/src/runner.ts b/src/runner.ts index c2066cb..53e25e0 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -2,7 +2,6 @@ import type { Worker } from 'node:worker_threads'; import type { ChannelOptions } from './channel.ts'; declare const resultBrand: unique symbol; -declare const argsBrand: unique symbol; /** An inert task descriptor. Carries the result type `T` and argument types `A` * at the type level, but is not itself a `PromiseLike` or `AsyncIterable`. @@ -10,12 +9,10 @@ declare const argsBrand: unique symbol; export type Task = { readonly uid: number; readonly id: string; - readonly args: unknown[]; + readonly args: A; worker?: WorkerHandle; /** @internal Type brand for result type inference. Not present at runtime. */ readonly [resultBrand]?: T; - /** @internal Type brand for argument type inference. Not present at runtime. */ - readonly [argsBrand]?: A; }; /** Resolves the dispatch return type: `AsyncIterable` for streaming tasks, `Promise` otherwise. */ diff --git a/test/fixtures/is-task.ts b/test/fixtures/is-task.ts new file mode 100644 index 0000000..40f17a4 --- /dev/null +++ b/test/fixtures/is-task.ts @@ -0,0 +1,13 @@ +import { mo } from 'moroutine'; + +export const checkPrime = mo(import.meta, (n: number): boolean => { + if (n < 2) return false; + for (let i = 2; i * i <= n; i++) if (n % i === 0) return false; + return true; +}); + +export const upper = mo(import.meta, (s: string): string => s.toUpperCase()); + +export const countUp = mo(import.meta, async function* (n: number) { + for (let i = 0; i < n; i++) yield i; +}); diff --git a/test/is-task.test.ts b/test/is-task.test.ts new file mode 100644 index 0000000..1184a73 --- /dev/null +++ b/test/is-task.test.ts @@ -0,0 +1,50 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { inert, isTask, workers } from 'moroutine'; +import type { Task } from 'moroutine'; +import { checkPrime, upper, countUp } from './fixtures/is-task.ts'; + +describe('isTask', () => { + it('exposes a stable id on the moroutine', () => { + assert.equal(typeof checkPrime.id, 'string'); + assert.ok(checkPrime.id.length > 0); + assert.notEqual(checkPrime.id, upper.id); + }); + + it('identifies tasks produced by a moroutine', () => { + const t = inert(checkPrime(7)); + assert.ok(isTask(checkPrime, t)); + assert.ok(!isTask(upper, t)); + }); + + it('works across value and streaming moroutines', () => { + const v = inert(checkPrime(7)); + const s = inert(countUp(3)); + assert.ok(isTask(checkPrime, v)); + assert.ok(!isTask(countUp, v)); + assert.ok(isTask(countUp, s)); + assert.ok(!isTask(checkPrime, s)); + }); + + it('narrows task args for downstream dispatch', async () => { + using run = workers(1); + const tasks: Array | Task> = [ + inert(checkPrime(5)), + inert(upper('hi')), + ]; + const results: Array = []; + for (const t of tasks) { + if (isTask(checkPrime, t)) { + // t is Task — args typed as [n: number] + const [n] = t.args; + assert.equal(typeof n, 'number'); + results.push(await run(t)); + } else { + const [s] = t.args; + assert.equal(typeof s, 'string'); + results.push(await run(t)); + } + } + assert.deepEqual(results, [true, 'HI']); + }); +}); -- 2.51.2