A poll-based async runtime for TypeScript inspired by Rust's Future trait. Zero Promises under the hood. Zero async/await required. Fully lazy — nothing executes until Future.run() is called.
Promises are push-based. The moment you construct one, it starts executing and pushes its result to whoever is listening.
// This starts immediately — you have no say in the matter
const p = new Promise((resolve) => {
console.log("already running"); // fires right now
setTimeout(resolve, 1000);
});Futures are pull-based. A Future is an inert description of a computation. Nothing happens until the runtime explicitly drives it by calling poll.
// This does nothing — yet
const f = new TimerFuture(1000);
// Now it starts
Future.run(f, () => console.log("1 second passed"));That difference — lazy vs eager — is what makes futures composable, cancellable, and safe to pass around as values before you decide to execute them.
npm install @michthemaker/futures
# or
pnpm add @michthemaker/futuresFor Node.js HTTP utilities:
import { FetchFuture } from "@michthemaker/futures/node";Every Future has a single method: poll(waker). The runtime calls it to ask "are you done?".
- If ready: return
{ ready: true, value }. - If not ready: store the
waker, return{ ready: false, value: undefined }, and callwaker()later when progress is possible. The runtime re-polls on everywaker()invocation.
setTimeout and I/O callbacks are the only places wakers are ever called — all async boundaries live inside leaf futures.
Futures are single-use. Once driven by Future.run(), the instance is consumed. Running it again throws immediately. Create a new instance, or wrap construction in a factory function.
const future = new Ready(42);
Future.run(future, () => {}); // fine
Future.run(future, () => {}); // throws — already consumedimport { Future, TimerFuture } from "@michthemaker/futures";
const timer = new TimerFuture(500);
// Nothing has happened yet. The timer hasn't started.
Future.run(timer, () => {
console.log("500ms elapsed");
});
// Timer starts NOW.import { Future, Ready } from "@michthemaker/futures";
Future.run(new Ready(42), (value) => {
console.log(value); // 42
});.andThen(fn) sequences futures. The callback receives the resolved value of the current future and must return the next Future. The second future doesn't start until the first completes.
import { Future, TimerFuture, Ready } from "@michthemaker/futures";
const pipeline = new TimerFuture(300)
.andThen(() => new Ready("step one done"))
.andThen((msg) => {
console.log(msg); // "step one done"
return new TimerFuture(200);
})
.andThen(() => new Ready("all done"));
Future.run(pipeline, (value) => {
console.log(value); // "all done"
});Chains are as long as you need. Each step is lazy — no future in the chain starts until the previous one resolves.
Future.all runs futures concurrently and resolves when every one of them has completed. Results are returned as a tuple in the original order, regardless of which future finished first.
import { Future, TimerFuture, Ready } from "@michthemaker/futures";
const a = new TimerFuture(100).andThen(() => new Ready(1));
const b = new TimerFuture(300).andThen(() => new Ready("hello"));
const c = new Ready(true);
Future.run(Future.all([a, b, c]), ([n, s, flag]) => {
console.log(n, s, flag); // 1, "hello", true — after ~300ms
});TypeScript infers the tuple type from the input, so [n, s, flag] is fully typed as [number, string, boolean].
Future.race resolves with the first future to complete. All remaining futures are immediately cancelled.
import { Future, TimerFuture } from "@michthemaker/futures";
const slow = new TimerFuture(2000);
const fast = new TimerFuture(100);
Future.run(Future.race([slow, fast]), () => {
console.log("fastest won"); // fires after ~100ms
// `slow` has been cancelled — its timer is cleared
});All futures passed to race must share the same value type.
There are no try/catch blocks in this library. Errors are values, via Result<T, E>.
import { Result } from "@michthemaker/futures";
const ok = Result.Ok(42);
const err = Result.Err("something went wrong");
console.log(ok.ok); // true
console.log(ok.value); // 42
console.log(err.ok); // false
console.log(err.error); // "something went wrong"Use Result.isResult(value) to discriminate at runtime. It uses an internal unforgeable symbol tag — plain objects with an ok property will never accidentally match.
if (Result.isResult(value) && !value.ok) {
console.error(value.error);
}If a future in an andThen chain resolves with a Result.Err, the chain is short-circuited. The callback is skipped and the error is passed through as-is to the final onComplete.
import { Future, Ready, Result } from "@michthemaker/futures";
const pipeline = new Ready(Result.Err("bad input")).andThen(
(v) => new Ready(Result.Ok("this never runs"))
);
Future.run(pipeline, (value) => {
console.log(value); // { ok: false, error: "bad input" }
});Every Future has a cancel() method. The base implementation is a no-op — leaf futures like TimerFuture and FetchFuture override it to clean up their resources.
Cancellation propagates through combinator chains.
import { Future, TimerFuture } from "@michthemaker/futures";
const chain = new TimerFuture(5000).andThen(() => new TimerFuture(5000));
Future.run(chain, () => console.log("done"));
// Some time later — cancel everything
chain.cancel(); // clears the active timer and any downstream futureFuture.all and Future.race also propagate cancel() to all their constituent futures.
YieldNow resolves after one event loop tick (setTimeout(fn, 0)). Use it to let other pending work run before continuing a chain.
import { Future, Ready, YieldNow } from "@michthemaker/futures";
const pipeline = new Ready("start")
.andThen(() => new YieldNow())
.andThen(() => new Ready("resumed after yield"));
Future.run(pipeline, (value) => {
console.log(value); // "resumed after yield"
});FetchFuture is a full HTTP client built on node:http and node:https. No fetch API, no Promises — just a Future that resolves to Result<FetchResponse<T>, FetchError>.
import { Future, FetchFuture, Result } from "@michthemaker/futures/node";
type Todo = { id: number; title: string; completed: boolean };
Future.run(
new FetchFuture<Todo>("https://jsonplaceholder.typicode.com/todos/1"),
(result) => {
if (!result.ok) {
console.error(result.error.kind, result.error.message);
return;
}
console.log(result.value.data); // Todo
console.log(result.value.status); // 200
}
);new FetchFuture("https://api.example.com/items", {
method: "POST",
headers: { Authorization: "Bearer token" },
body: { name: "widget", qty: 3 }, // auto JSON.stringify + Content-Type
query: { page: 1, limit: 20 }, // appended to URL
timeout: 5_000, // ms, default 30_000
parseResponse: true, // JSON.parse response body, default true
});GET · POST · PUT · PATCH · DELETE · HEAD · OPTIONS
kind |
When it occurs |
|---|---|
http |
Response status >= 400 — includes status field |
network |
ECONNREFUSED, ECONNRESET |
dns |
ENOTFOUND — domain doesn't exist |
timeout |
Request exceeded timeout ms |
parse |
Response body failed JSON.parse |
ssl |
Certificate errors |
aborted |
Request was aborted mid-flight |
invalid_url |
URL failed to parse |
import {
Future,
FetchFuture,
TimerFuture,
Result,
} from "@michthemaker/futures/node";
type User = { id: number; name: string };
type Posts = { userId: number; title: string }[];
const pipeline = new FetchFuture<User>(
"https://api.example.com/users/1"
).andThen((result) => {
if (!result.ok) return new Ready(result); // propagate error
const userId = result.value.data.id;
return new FetchFuture<Posts>(
`https://api.example.com/posts?userId=${userId}`
);
});
Future.run(pipeline, (result) => {
if (!result.ok) {
console.error(result.error);
return;
}
console.log(result.value.data); // Posts[]
});Extend Future<T> and implement poll. The entire async contract lives there.
import { Future } from "@michthemaker/futures";
class DelayedValue<T> extends Future<T> {
private value: T;
private ms: number;
private timerId: ReturnType<typeof setTimeout> | null = null;
constructor(value: T, ms: number) {
super();
this.value = value;
this.ms = ms;
}
poll(waker: () => void) {
if (this.done) return { ready: true, value: this.value };
if (!this.timerId) {
this.timerId = setTimeout(() => {
this.done = true;
waker();
}, this.ms);
}
return { ready: false, value: undefined };
}
cancel() {
if (this.timerId !== null) {
clearTimeout(this.timerId);
this.timerId = null;
}
}
}
Future.run(new DelayedValue("hello", 500), (v) => {
console.log(v); // "hello" after 500ms
});Rules:
- Call
waker()exactly once when the future is ready to be re-polled. - Never call
polldirectly — always useFuture.run(). - Override
cancel()if you hold any resources that need cleanup. - Guard with
if (this.done)at the top ofpoll— the runtime may re-poll after resolution.
Because futures are single-use, the idiomatic pattern for reuse is a factory function:
const getTodo = (id: number) =>
new FetchFuture<Todo>(`https://jsonplaceholder.typicode.com/todos/${id}`);
Future.run(getTodo(1), handler);
Future.run(getTodo(2), handler); // fresh instance, no shared state| Member | Description |
|---|---|
abstract poll(waker) |
Implement this. Returns { ready, value }. |
static run(future, onComplete) |
The only way to execute a future. |
static all(futures) |
Resolves when all futures complete. Tuple-typed. |
static race(futures) |
Resolves with the first to complete. Cancels the rest. |
.andThen(fn) |
Sequences this future into the next. Returns AndThen. |
.cancel() |
Stops pending work. No-op in base class. |
| Class | Resolves to | Notes |
|---|---|---|
Ready<T> |
T |
Immediately on first poll. |
TimerFuture |
undefined |
After ms milliseconds. |
YieldNow |
undefined |
After one event loop tick. |
| Class | Resolves to | Notes |
|---|---|---|
AndThen<T, U> |
U |
Second future; short-circuits on Result.Err. |
All<T[]> |
T[] |
All results, original order. |
Race<T> |
T |
First to resolve; cancels losers. |
| Member | Description |
|---|---|
Result.Ok(value) |
Wraps a success value. .ok === true, .value. |
Result.Err(error) |
Wraps a failure value. .ok === false, .error. |
Result.isResult(value) |
Symbol-tagged runtime check. |
MIT