diff --git a/README.md b/README.md index 53760b9..7fa3644 100644 --- a/README.md +++ b/README.md @@ -11,30 +11,34 @@ npm install typectl Async orchestration code tends to look like this: ```typescript -const a = await fetchA() -const b = await fetchB() // waits for A, even though it doesn't need to -const c = await computeC(a, b) +async function getProfile(userId: string) { + const user = await fetchUser(userId) + const posts = await fetchPosts(userId) // waits for user, even though it doesn't need to + const profile = formatProfile(user, posts) + return profile +} ``` -`fetchB` doesn't depend on `fetchA`, yet it waits. You can fix it with `Promise.all`, but as dependencies grow the wiring becomes fragile and hard to read. Typectl eliminates this problem entirely. +`fetchPosts` doesn't depend on `fetchUser`, yet it waits. You can fix it with `Promise.all`, but as dependencies grow the wiring becomes fragile and hard to read. Typectl eliminates this problem entirely. -With typectl, you describe **what depends on what** and let the runtime figure out the optimal execution order: +With typectl, you describe **what depends on what** and the runtime figures out the rest: ```typescript -import { wrap, all } from "typectl" - -const fetchAW = wrap(fetchA) -const fetchBW = wrap(fetchB) -const computeCW = wrap(computeC) +import { wrap } from "typectl" -const a = fetchAW() -const b = fetchBW() // runs concurrently with A -const c = computeCW(a, b) // automatically waits for both A and B +const fetchUserW = wrap(fetchUser) +const fetchPostsW = wrap(fetchPosts) +const formatProfileW = wrap(formatProfile) -const [resultA, resultB, resultC] = await all([a, b, c]) +function getProfile(userId: string) { + const user = fetchUserW(userId) + const posts = fetchPostsW(userId) // runs concurrently with fetchUser + const profile = formatProfileW(user, posts) // waits for both, then runs + return profile +} ``` -No manual `await` placement. No `Promise.all` juggling. Functions execute as soon as their arguments resolve. +No `async`. No `await`. No `Promise.all`. The function reads like synchronous code, returns a `Promise`, and the library resolves the dependency graph for you. ## Core concept @@ -42,6 +46,96 @@ The key primitive is `wrap`. Wrapping a function makes every argument accept eit By passing promise return values to successive wrapped functions, you build a dependency graph that resolves itself optimally at runtime. +## Example + +This example is located at [`src/example`](src/example). + +### `functions.ts` + +```typescript +export async function fetchUser(id: string) { + return { id, name: "Alice", email: "alice@example.com" } +} + +export async function fetchPosts(userId: string) { + return [ + { userId, title: "First post", likes: 3 }, + { userId, title: "Second post", likes: 7 }, + ] +} + +export function formatProfile( + user: { id: string; name: string; email: string }, + posts: { userId: string; title: string; likes: number }[] +) { + return { + displayName: user.name, + email: user.email, + postCount: posts.length, + totalLikes: posts.reduce((sum, p) => sum + p.likes, 0), + } +} +``` + +### `controlFlow.ts` + +```typescript +import { wrap } from "typectl" +import { fetchUser, fetchPosts, formatProfile } from "./functions" + +const fetchUserW = wrap(fetchUser) +const fetchPostsW = wrap(fetchPosts) +const formatProfileW = wrap(formatProfile) + +export default function getProfile(userId: string) { + const user = fetchUserW(userId) + const posts = fetchPostsW(userId) + const profile = formatProfileW(user, posts) + return { user, posts, profile } +} +``` + +There is no `await` anywhere in `getProfile`. `fetchUser` and `fetchPosts` run concurrently because neither depends on the other. `formatProfile` automatically waits for both before running. The dependency graph: + +``` +fetchUser ──┐ + ├── formatProfile +fetchPosts ─┘ +``` + +### `spec.ts` + +```typescript +import { describe, it, expect } from "vitest" +import getProfile from "./controlFlow" + +describe("example", () => { + it("builds a profile without awaits in the control flow", async () => { + const { user, posts, profile } = getProfile("user-1") + + expect(await user).toEqual({ + id: "user-1", + name: "Alice", + email: "alice@example.com", + }) + + expect(await posts).toEqual([ + { userId: "user-1", title: "First post", likes: 3 }, + { userId: "user-1", title: "Second post", likes: 7 }, + ]) + + expect(await profile).toEqual({ + displayName: "Alice", + email: "alice@example.com", + postCount: 2, + totalLikes: 10, + }) + }) +}) +``` + +`await` only appears in the test — at the consumption boundary where you actually need the resolved values. + ## API reference ### `wrap(fn)` @@ -318,68 +412,6 @@ await promiseCall(() => 42) // → 42 await promiseCall(async () => 42) // → 42 ``` -## Full example - -This example is located at [`src/example`](src/example). - -### `functions.ts` - -```typescript -export function time() { - return new Date().getTime() -} - -export function plusOne(value: number) { - return value + 1 -} -``` - -### `controlFlow.ts` - -```typescript -import { all, pick, toArray, toRecord } from "typectl" - -export default function () { - const functions = import("./functions") - const time = pick(functions, "time") - const plusOne = pick(functions, "plusOne") - const times = all([time, time]) - const timesPlusOne = toArray(times, plusOne) - const timesPlusOneRecord = toRecord(timesPlusOne) - return { times, timesPlusOneRecord } -} -``` - -Notice there is no `await` anywhere in the control flow. The dynamic `import()` is a `Promise`, `pick` extracts wrapped functions from it, `all` executes them concurrently, and `toArray`/`toRecord` transform the results — all without blocking. - -### `spec.ts` - -```typescript -import { describe, it, expect } from "vitest" -import { pick } from "typectl" -import controlFlow from "./controlFlow" - -describe("example", () => { - it("runs control flow", async () => { - const { times, timesPlusOneRecord } = controlFlow() - - expect(await times).toEqual([ - expect.any(Number), - expect.any(Number), - ]) - - expect(await timesPlusOneRecord).toEqual({ - 0: expect.any(Number), - 1: expect.any(Number), - }) - - expect(await pick(times, 0)).toEqual( - (await pick(timesPlusOneRecord, 0)) - 1 - ) - }) -}) -``` - ## Type exports Typectl exports the following utility types for advanced use: diff --git a/src/example/controlFlow.ts b/src/example/controlFlow.ts index 651faf2..cc8cdd0 100644 --- a/src/example/controlFlow.ts +++ b/src/example/controlFlow.ts @@ -1,11 +1,17 @@ -import { all, pick, toArray, toRecord } from "../typectl" +import { wrap } from "../typectl" +import { + fetchUser, + fetchPosts, + formatProfile, +} from "./functions" -export default function () { - const functions = import("./functions") - const time = pick(functions, "time") - const plusOne = pick(functions, "plusOne") - const times = all([time, time]) - const timesPlusOne = toArray(times, plusOne) - const timesPlusOneRecord = toRecord(timesPlusOne) - return { times, timesPlusOneRecord } +const fetchUserW = wrap(fetchUser) +const fetchPostsW = wrap(fetchPosts) +const formatProfileW = wrap(formatProfile) + +export default function getProfile(userId: string) { + const user = fetchUserW(userId) + const posts = fetchPostsW(userId) + const profile = formatProfileW(user, posts) + return { user, posts, profile } } diff --git a/src/example/functions.ts b/src/example/functions.ts index cf212fe..be3b995 100644 --- a/src/example/functions.ts +++ b/src/example/functions.ts @@ -1,7 +1,22 @@ -export function time() { - return new Date().getTime() +export async function fetchUser(id: string) { + return { id, name: "Alice", email: "alice@example.com" } } -export function plusOne(value: number) { - return value + 1 +export async function fetchPosts(userId: string) { + return [ + { userId, title: "First post", likes: 3 }, + { userId, title: "Second post", likes: 7 }, + ] +} + +export function formatProfile( + user: { id: string; name: string; email: string }, + posts: { userId: string; title: string; likes: number }[] +) { + return { + displayName: user.name, + email: user.email, + postCount: posts.length, + totalLikes: posts.reduce((sum, p) => sum + p.likes, 0), + } } diff --git a/src/example/spec.ts b/src/example/spec.ts index 30af9cd..233e639 100644 --- a/src/example/spec.ts +++ b/src/example/spec.ts @@ -1,23 +1,26 @@ import { describe, it, expect } from "vitest" -import { pick } from "../typectl" -import controlFlow from "./controlFlow" +import getProfile from "./controlFlow" describe("example", () => { - it("runs control flow", async () => { - const { times, timesPlusOneRecord } = controlFlow() + it("builds a profile without awaits in the control flow", async () => { + const { user, posts, profile } = getProfile("user-1") - expect(await times).toEqual([ - expect.any(Number), - expect.any(Number), + expect(await user).toEqual({ + id: "user-1", + name: "Alice", + email: "alice@example.com", + }) + + expect(await posts).toEqual([ + { userId: "user-1", title: "First post", likes: 3 }, + { userId: "user-1", title: "Second post", likes: 7 }, ]) - expect(await timesPlusOneRecord).toEqual({ - 0: expect.any(Number), - 1: expect.any(Number), + expect(await profile).toEqual({ + displayName: "Alice", + email: "alice@example.com", + postCount: 2, + totalLikes: 10, }) - - expect(await pick(times, 0)).toEqual( - (await pick(timesPlusOneRecord, 0)) - 1 - ) }) }) diff --git a/src/spec.ts b/src/spec.ts index 5643587..5e00627 100644 --- a/src/spec.ts +++ b/src/spec.ts @@ -59,11 +59,15 @@ describe("typectl", () => { }) it("pick", async () => { - const plusOne = await pick( + const fetchUser = await pick( import("./example/functions"), - "plusOne" + "fetchUser" ) - expect(await plusOne(1)).toBe(2) + expect(await fetchUser("test-1")).toEqual({ + id: "test-1", + name: "Alice", + email: "alice@example.com", + }) const x: Promise = Promise.resolve(new TestClass())