Dependency-free. Tree-shakeable. Side-effect free. About 3.3 kB gzipped for the core, with stream helpers on a separate 2.7 kB subpath.
yeet is what happens when Either stops being a ceremonial robe and starts
doing field work.
Write normal JavaScript. yield* a value to unwrap success. Hit a Left, and
the computation exits with typed failure data. Rejected promises become
Left<Rejected>. Aborts become Left<Aborted>. Forked child work belongs to
the current generator and gets cancelled with it. Streams, bytes, schemas, and
wire-friendly outcomes all speak the same small language.
No runtime dependencies. No method-chain cathedral. No pipe-operator pilgrimage. Just ordinary control flow, with TypeScript quietly keeping score.
import { either } from '@big-time/yeet'
import { json } from '@big-time/yeet/stream'
const result = await either(async function* ({ raise, signal }) {
const [user, settings] = yield* await signal.forkAll([
(signal) => fetchUser(id, signal),
(signal) => fetchSettings(id, signal),
] as const)
if (!user.active) {
return raise({ _tag: 'InactiveUser' as const, id: user.id })
}
const response = yield* await raise(
fetch(`/api/profile/${user.id}`, { signal }),
)
const profile = yield* await json(response, { signal })
return { user, settings, profile }
})
// inferred:
// Promise<
// Either<
// | Aborted
// | Rejected
// | FetchUserError
// | FetchSettingsError
// | StreamError
// | { _tag: "InactiveUser"; id: string },
// { user: User; settings: Settings; profile: unknown }
// >
// >That is the trick: errors are values, cancellation is a value, stream failures
are values, and scoped concurrency still comes back as an Either. Add the
optional unplugin and supported generator flows lower to plain branches at build
time, like the narrator quietly removing the scaffolding after the bridge is
built.
The runtime stays tiny. The source stays boring in the best way. The types do the remembering.
Good. A library named yeet should earn your trust before it starts carrying
your checkout flow across the river.
The core is plain tagged data:
left(error) // { _tag: "Left", error }
right(value) // { _tag: "Right", value }either(function* () { ... }) is just a small runner for those values. If a
Left appears, it stops and returns it. If everything is Right, it returns
the final value. There is no hidden global state, no ambient context store, no
runtime dependency quietly playing the violin in the walls.
The fancier parts are opt-in:
- the build-time optimizer is only an optimization; unsupported code is left alone and still runs through the normal runtime
- cancellation is cooperative and explicit; pass the
signalto I/O that needs to stop - scoped forks belong to the current async
either; when the generator exits, live children are aborted and awaited - stream helpers live on
@big-time/yeet/stream, with size limits for the places where "just read it all" becomes a haunted sentence - schemas accept Standard Schema-compatible validators like Zod, Valibot, ArkType, or TypeBox, but yeet imports none of them
You can start with only left, right, and either. The rest of the library
waits politely until you ask for it.
- Install
- Quick Start
- Core Model
- Synchronous Flows
- Async Flows
- Cancellation
- Streams And Bytes
- Composition Helpers
- Serialization And Schemas
- Build-Time Optimizer
- Low-Level Folding
- API Reference
- Benchmarks
- License
npm install @big-time/yeet
pnpm add @big-time/yeet
yarn add @big-time/yeet
bun add @big-time/yeetyeet is ESM-only, ships TypeScript declarations, and has zero runtime
dependencies.
import { either, left, right, type Either } from '@big-time/yeet'
type User = { id: string; active: boolean }
type Order = { id: string; userId: string }
const getUser = (id: string): Either<'UserNotFound', User> =>
id === '1' ? right({ id, active: true }) : left('UserNotFound')
const getOrders = (userId: string): Either<'DbError', Order[]> =>
right([{ id: 'order-1', userId }])
const result = either(function* (raise) {
const user = yield* getUser('1')
if (!user.active) return raise('Inactive' as const)
const orders = yield* getOrders(user.id)
return { user, orders }
})
// inferred:
// Either<
// "UserNotFound" | "Inactive" | "DbError",
// { user: User; orders: Order[] }
// >If every yielded value is a Right, the computation returns Right with the
final value. If any yielded value is a Left, execution stops there and that
Left becomes the result. A door closes, gently but with conviction.
An Either<E, A> is one of two values:
left(error) // inferred: Left<E>
right(value) // inferred: Right<A>You can inspect it with the _tag field or with helpers:
import { isLeft, isRight } from '@big-time/yeet'
if (isRight(result)) {
result.value
// inferred: result is Right<A>
}
if (isLeft(result)) {
result.error
// inferred: result is Left<E>
}Inside either(...), yield* unwraps a Right and short-circuits on a Left:
const result = either(function* () {
const value = yield* right(42)
yield* left('Nope')
return value
})
// inferred: Either<'Nope', 42>Returning raise(error) is the typed early-exit move. It also helps TypeScript
understand control flow:
const result = either(function* (raise) {
const user = yield* getUser(id)
if (!user.active) return raise('Inactive' as const)
return user
})
// inferred: Either<'UserNotFound' | 'Inactive', User>There are no annotations in that function body. The error union is inferred from the things you yield and raise.
Use either(function* () { ... }) when every step is synchronous:
const checkout = either(function* (raise) {
const session = yield* getSession('session-1')
if (!session.checkoutEnabled) return raise('CheckoutDisabled' as const)
const user = yield* getUser(session.userId)
const cart = yield* getCart(user.id)
return { user, cart }
})
// inferred:
// Either<
// SessionError | "CheckoutDisabled" | UserError | CartError,
// { user: User; cart: Cart }
// >If getSession, getUser, or getCart returns a Left, execution stops at
that line. Otherwise the unwrapped success value continues downstream, like a
quiet river in a documentary about responsible software.
ensure and ensureNotNull cover common checks without making you write tiny
one-off Either factories:
import { either, ensure, ensureNotNull } from '@big-time/yeet'
const result = either(function* (raise) {
const id = yield* ensureNotNull(input.userId, () => 'MissingUserId' as const)
yield* ensure(id.length > 0, () => 'EmptyUserId' as const)
const user = yield* getUser(id)
if (!user.active) return raise('Inactive' as const)
return user
})
// inferred:
// Either<
// "MissingUserId" | "EmptyUserId" | "UserNotFound" | "Inactive",
// User
// >Async generators work the same way. Await the Either, then yield* it:
const result = await either(async function* (raise) {
const user = yield* await fetchUser('1')
const orders = yield* await fetchOrders(user.id)
if (orders.length === 0) return raise('NoOrders' as const)
return { user, orders }
})
// inferred:
// Either<
// FetchUserError | FetchOrdersError | "NoOrders",
// { user: User; orders: Order[] }
// >Promises and thenables can go through raise(promiseLike). Rejections become
Left<Rejected> instead of escaping as thrown exceptions:
import { either } from '@big-time/yeet'
const result = await either(async function* (raise) {
const response = yield* await raise(fetch('/api/user'))
if (!response.ok) {
return raise({ _tag: 'HttpError' as const, status: response.status })
}
const data = yield* await raise(() => response.json() as Promise<unknown>)
return data
})
// inferred:
// Either<
// Rejected | { _tag: "HttpError"; status: number },
// unknown
// >If starting the operation can throw synchronously, pass a function. raise(fn)
uses Promise.try, so both synchronous throws and rejected promises become
Left<Rejected>:
type Config = { port: number }
const config =
yield * (await raise(() => JSON.parse(readConfigFile()) as Config))
// inferred: ConfigUse the direct form when you already have a promise:
const response = yield * (await raise(fetch('/api/user')))
// inferred: ResponseUse the function form when creating the promise may throw before a promise exists:
type Payload = { id: string }
const parsed = yield * (await raise(() => JSON.parse(input) as Payload))
// inferred: PayloadPass an AbortSignal as the first argument to make an async either flow
cooperatively cancellable:
const result = await either(signal, async function* ({ raise, signal }) {
using conn = yield* openConn()
const user = yield* await fetchUser(id, signal)
const avatar = yield* await raise(
fetch(user.avatarUrl, {
signal,
}),
)
return { user, avatar, conn }
})
// inferred:
// Either<
// Aborted | OpenConnError | FetchUserError | Rejected,
// { user: User; avatar: Response; conn: Conn }
// >The first callback parameter is a RaiseContext: still callable like raise,
but also destructurable when you want the signal without the awkward little
shadow puppet of raise.signal everywhere.
type ScopeSignal = AbortSignal & {
fork<E, A>(
task: (signal: ScopeSignal) => Either<E, A> | PromiseLike<Either<E, A>>,
): Promise<Exit<E, A>>
forkAll<const T extends readonly ScopeTask<any, any>[]>(
tasks: T,
): Promise<Exit<ScopeTaskError<T[number]>, ScopeTaskValues<T>>>
forkRace<const T extends readonly ScopeTask<any, any>[]>(
tasks: T,
): Promise<Exit<ScopeTaskError<T[number]>, ScopeTaskValue<T[number]>>>
}
type Exit<E, A> = Either<E | Rejected | Aborted, A>
type RaiseContext = Raise & {
readonly raise: Raise
readonly signal: ScopeSignal
}
type AbortRaise = RaiseContextUse async function* ({ raise, signal }) when you need both. If you only need
the signal, destructure only that:
const result = await either(signal, async function* ({ signal }) {
return yield* await fetchUser(id, signal)
})If you prefer the old single-name style, async function* (raise) { ... } still
works and raise.signal is there. For compatibility, yeet also passes the same
enriched child signal as the callback's second argument:
async function* (raise, signal) { ... }. Prefer destructuring in new examples
so the source of the signal has one obvious home.
The injected signal exists even when you do not pass a parent signal. Touching
{ signal } inside an async either lazily opens a tiny scope. From there,
signal.fork(task) starts child work under that scope and gives the task its own
child ScopeSignal.
const result = await either(async function* ({ signal }) {
const user = signal.fork((signal) => fetchUser(id, signal))
const settings = signal.fork((signal) => fetchSettings(id, signal))
return {
user: yield* await user,
settings: yield* await settings,
}
})
// inferred:
// Promise<
// Either<
// Aborted | Rejected | FetchUserError | FetchSettingsError,
// { user: User; settings: Settings }
// >
// >If any fork returns a Left or rejects, yeet aborts the scope signal, sibling
tasks see signal.aborted, and the outer either returns that failure as data.
On normal return, short-circuit, throw, or parent abort, outstanding forks are
aborted and awaited before the result settles. The spell is small, but it is a
real step toward structured concurrency: children do not wander off after the
generator is done.
For the cleanest inferred error unions, yield* await the fork promises you
care about, as above. TypeScript cannot see the error type of a detached fork
that is started and never referenced again; JavaScript may be magical, but it is
not yet clairvoyant.
When the work is naturally a batch, use signal.forkAll. It starts every task
with a child signal, returns values in input order, and cancels siblings on the
first Left or rejection.
const result = await either(async function* ({ signal }) {
const [user, settings] = yield* await signal.forkAll([
(signal) => fetchUser(id, signal),
(signal) => fetchSettings(id, signal),
] as const)
return { user, settings }
})
// inferred:
// Promise<Either<Aborted | Rejected | FetchUserError | FetchSettingsError, { user: User; settings: Settings }>>Use signal.forkRace when the first typed outcome wins. A winning Right
aborts the losers without poisoning the enclosing either; a winning Left
aborts the losers and short-circuits as usual.
const result = await either(async function* ({ signal }) {
const profile = yield* await signal.forkRace([
(signal) => fetchFromEdgeCache(id, signal),
(signal) => fetchFromOrigin(id, signal),
] as const)
return profile
})
// inferred:
// Promise<Either<Aborted | Rejected | EdgeError | OriginError, Profile>>When the signal aborts, yeet returns Left<Aborted> and calls gen.return(),
so finally, using, and await using cleanup get their turn.
type Aborted = { readonly _tag: 'Aborted'; readonly reason: unknown }That reason is honestly unknown. controller.abort() with no argument gives
you the platform's default AbortError DOMException; controller.abort(x)
gives you x. Yeet does not comb its hair into a library-shaped error for you.
Cancellation is cooperative, because JavaScript is cooperative. The driver can stop advancing the generator and unwind resources, but it cannot interrupt synchronous CPU-bound work, and it cannot cancel an in-flight promise unless that operation honors the same signal. Pass the signal to both layers: yeet for the flow boundary, your I/O for the actual work.
If the current awaited operation ignores the signal, yeet requests
gen.return() immediately, but the returned promise cannot settle until the
generator reaches a point where JavaScript can unwind it. Responsiveness is
bounded by the longest in-flight step. If that step ignores the signal and never
settles, either(signal, ...) waits forever, patiently holding the lantern.
If cleanup itself throws during abort unwind, that thrown error wins. Multiple
throwing using / await using disposers follow JavaScript's SuppressedError
rules, so the earlier cleanup failure is still chained instead of vanishing
under the floorboards.
Stream helpers live on a separate subpath so the core stays tiny:
import { bytes, collectText, consume, ndjson, sse } from '@big-time/yeet/stream'They are dependency-free and built for the sort of code that reads request
bodies, AI SDK deltas, NDJSON tool streams, and server-sent events. The rule is
simple: helpers that return one final value return Promise<Either<...>>;
helpers that produce many values are async iterables of Either, so each item
can be handled with the same old yield*.
Stream helpers also compose with the build-time optimizer in non-abortable
flows. Bounded steps like yield* await json(body) and structured item steps
like for await (const next of ndjson(body)) { const item = yield* next }
lower to plain awaits, loops, and Left checks. The stream does its real
work; the do-notation furniture disappears before the guests arrive.
Use bytes, text, and json when you want one bounded result:
import { either } from '@big-time/yeet'
import { bytes } from '@big-time/yeet/stream'
const result = await either(signal, async function* ({ signal }) {
const file = yield* await bytes(request, {
maxBytes: 25_000_000,
signal,
})
const doc = yield* await extractText(file)
return yield* await indexDocument(doc)
})
// inferred:
// Promise<
// Either<
// Aborted | StreamError | ExtractTextError | IndexDocumentError,
// IndexedDocument
// >
// >bytes accepts Request / Response bodies, Blob, ReadableStream,
AsyncIterable, ArrayBuffer, and Uint8Array-ish views. Direct byte inputs
are returned without copying; multiple chunks are copied once at the end.
For token or text streams, collectText avoids allocating a Right for every
successful chunk. It drains the stream, optionally tees each delta, and joins
once:
import { either } from '@big-time/yeet'
import { collectText } from '@big-time/yeet/stream'
const result = await either(signal, async function* ({ signal }) {
const text = yield* await collectText(generation.textStream, {
tee: (delta) => writer.write(delta),
maxChars: 200_000,
signal,
error: providerError.promise,
})
return text
})
// inferred: Promise<Either<Aborted | StreamError, string>>If you do not want a final string, use consume(source, { each, signal }).
each may return a Left to stop early, and throws/rejections become
Left<StreamConsumerError>.
const result = await consume(generation.textStream, {
signal,
each(delta) {
writer.write(delta)
meter.add(delta.length)
},
})
// inferred: Promise<Either<Aborted | StreamError, void>>For protocols where each item can fail independently, use the async iterable
helpers. They allocate an Either per parsed item because that is what makes
yield* next work. In exchange, the loop stays ordinary JavaScript:
import { either } from '@big-time/yeet'
import { sse } from '@big-time/yeet/stream'
const result = await either(signal, async function* ({ raise, signal }) {
const res = yield* await raise(fetch(url, { signal }))
for await (const next of sse(res.body, { signal })) {
const event = yield* next
if (event.event === 'error') {
return raise({ _tag: 'ProviderError' as const, data: event.data })
}
yield* await handleProviderEvent(event)
}
return 'done' as const
})
// inferred:
// Promise<
// Either<
// Aborted | Rejected | StreamError | ProviderError | HandleProviderEventError,
// "done"
// >
// >NDJSON reads the same way:
import { either } from '@big-time/yeet'
import { ndjson } from '@big-time/yeet/stream'
const result = await either(signal, async function* ({ signal }) {
for await (const next of ndjson(toolResultStream, {
maxBytes: 1_000_000,
signal,
})) {
const event = yield* next
const valid = yield* validateToolEvent(event)
yield* await saveEvent(valid)
}
return 'ok' as const
})
// inferred:
// Promise<
// Either<
// Aborted | StreamError | ValidateToolEventError | SaveEventError,
// "ok"
// >
// >Cancellation follows the same cooperative rule as either(signal, ...): pass
the signal to the driver and to the stream helper. If the source ignores the
signal and never settles, yeet cannot summon a settlement from the deep. It can
only stop advancing once JavaScript hands control back.
The helpers in this section are still just functions. No DSL hatch opens in the ceiling. They cover the cases where plain short-circuiting is not quite the story you want to tell.
| Helper | What It Does |
|---|---|
capture(either) |
Treat a Left as ordinary data inside either |
all(inputs) |
Start independent sync/async inputs together and short-circuit by input order |
collectAll(inputs) |
Start independent inputs together and partition successes/failures |
validate(fn) |
Run every check and accumulate all errors |
firstOf(fn) |
Return the first successful yielded Either |
collect(fn) |
Partition every yielded Either into { values, errors } |
Most of the time, yield* left(...) should stop the computation. Sometimes you
want to catch that Left as data: retry, log, ignore, or decide whether to
re-raise it yourself.
import { capture, either } from '@big-time/yeet'
const result = either(function* (raise) {
const cached = yield* capture(getUserFromCache(id))
if (cached._tag === 'Right') {
return cached.value
}
if (cached.error !== 'CacheMiss') {
return raise(cached.error)
}
return yield* getUserFromDatabase(id)
})
// inferred: Either<CacheError | DatabaseError, User>capture(either) returns Right<Either<E, A>>, so the outer either(...)
unwraps the Right and hands you the original Either as an ordinary value. A
small trapdoor, tastefully installed.
Normal yield* await code is sequential. That is usually what you want, but
independent work can start together:
import { all, either } from '@big-time/yeet'
const result = await either(async function* () {
const [user, settings] = yield* await all([fetchUser(id), fetchSettings(id)])
return { user, settings }
})
// inferred:
// Either<
// Rejected | FetchUserError | FetchSettingsError,
// { user: User; settings: Settings }
// >all accepts Either, Promise<Either>, or thunks that return either of
those. Async inputs are observed concurrently. Promise rejections and
synchronous throws from thunks become Left<Rejected>.
The result is tuple-shaped, so each success keeps its own type:
const result = await all([
right(1),
Promise.resolve(right('two')),
() => right(true),
])
// inferred: Either<Rejected, [number, string, boolean]>For async failures, all waits for the inputs to settle, then returns the first
Left by input order. No race-condition fortune telling.
const result = await all([
fetchSlowThing(), // eventually Left("SlowFailed")
fetchFastThing(), // eventually Left("FastFailed")
])
// inferred: Either<Rejected | "SlowFailed" | "FastFailed", [SlowThing, FastThing]>
// resolves to Left("SlowFailed")If the work itself can throw while starting, use thunks:
const result = await all([() => parseConfigFile(), () => fetchSettings()])
// inferred: Either<Rejected | ConfigError | SettingsError, [Config, Settings]>all expects each input to produce an Either. For raw promises, wrap them with
raise so rejection still becomes data:
const result = await either(async function* (raise) {
const [user, settings] = yield* await all([
raise(fetch('/api/user')),
raise(fetch('/api/settings')),
])
return { user, settings }
})
// inferred: Either<Rejected, { user: Response; settings: Response }>collectAll is the sibling that does not short-circuit. It runs the same input
shapes as all, then partitions everything:
import { collectAll } from '@big-time/yeet'
const { values, errors } = await collectAll(
ids.map((id) => () => fetchUser(id)),
)
// inferred:
// values: User[]
// errors: (Rejected | FetchUserError)[]Sometimes the first error is not enough. validate runs every check and returns
all failures as Left<E[]>.
import { left, right, validate, type Either } from '@big-time/yeet'
const validateAge = (n: number): Either<'TooYoung' | 'TooOld', number> =>
n < 0 ? left('TooYoung') : n > 150 ? left('TooOld') : right(n)
const validateName = (s: string): Either<'Empty' | 'TooLong', string> =>
s.length === 0 ? left('Empty') : s.length > 100 ? left('TooLong') : right(s)
const result = validate(function* (check) {
const age = yield* check(validateAge(input.age))
const name = yield* check(validateName(input.name))
return { age, name }
})
// inferred:
// Either<
// ("TooYoung" | "TooOld" | "Empty" | "TooLong")[],
// { age: number | undefined; name: string | undefined }
// >When a check fails, check(...) returns undefined inside the generator so the
rest of the validation can continue. The final result tells you whether the day
was won.
firstOf tries yielded Eithers in order and returns the first Right. If they
all fail, it returns every error:
import { firstOf } from '@big-time/yeet'
const user = firstOf(function* () {
yield getUserFromCache(id)
yield getUserFromReplica(id)
yield getUserFromPrimary(id)
})
// inferred: Either<Error[], User>collect partitions every yielded value into successes and failures:
import { collect } from '@big-time/yeet'
const { values, errors } = collect(function* () {
for (const item of items) {
yield processItem(item)
}
})
// inferred:
// values: ProcessedItem[]
// errors: ProcessItemError[]No short-circuiting. No judgment. Just two arrays, standing there in the light.
Left and Right serialize to small tagged JSON objects. Nothing clever is
hiding under the floorboards.
JSON.stringify(left('Nope'))
// {"_tag":"Left","error":"Nope"}
// inferred: string
JSON.stringify(right({ id: 'user-1' }))
// {"_tag":"Right","value":{"id":"user-1"}}
// inferred: stringtoJSON() eagerly converts nested values that provide their own toJSON.
Native Error objects become plain { name, message, ...fields } objects. This
keeps the returned transport object boring even in frameworks that inspect
prototypes before JSON encoding, as some server-function and RPC layers do.
class NotFound extends Error {
readonly _tag = 'NotFound'
toJSON() {
return { _tag: this._tag, message: this.message }
}
}
left(new NotFound('User not found')).toJSON()
// { _tag: 'Left', error: { _tag: 'NotFound', message: 'User not found' } }
// inferred: SerializedLeft<{ _tag: "NotFound"; message: string }>For trusted values that already have the serialized shape, fromJSON hydrates
them back into Left / Right instances:
import {
fromJSON,
isSerializedEither,
type SerializedEither,
} from '@big-time/yeet'
type User = { id: string }
const parsed = JSON.parse(json) as SerializedEither<string, User>
// inferred: SerializedEither<string, User>
if (isSerializedEither(parsed)) {
const result = fromJSON(parsed)
// inferred: Either<string, User>
}isSerializedEither(value) detects yeet's strict outer envelope. It does not
validate nested payloads; that is what schemas are for.
When the JSON came from outside the room, use a schema. yeet accepts Standard
Schema-compatible validators for the error and value payloads, so you can
bring Zod, Valibot, ArkType, TypeBox adapters, or whatever your project already
uses. yeet does not import any of them. It merely checks for ~standard and
lets the grown-ups speak for themselves.
With Zod, pass schemas directly when you want validation or hydration:
import * as z from 'zod'
import { eitherSchema, serializedEitherSchema } from '@big-time/yeet'
const ApiError = z.object({
code: z.string(),
message: z.string(),
})
const User = z.object({
id: z.string(),
email: z.email(),
})
type ApiError = z.infer<typeof ApiError>
type User = z.infer<typeof User>
const SerializedUserResult = serializedEitherSchema({
error: ApiError,
value: User,
})
// inferred: SerializedEitherSchema<ApiError, User>
const HydratedUserResult = eitherSchema({
error: ApiError,
value: User,
})
// inferred: EitherSchema<ApiError, User>
const parsed = await SerializedUserResult['~standard'].validate(
JSON.parse(json),
)
// inferred: Standard Schema result containing SerializedEither<ApiError, User>
const hydrated = await HydratedUserResult['~standard'].validate(
JSON.parse(json),
)
// inferred: Standard Schema result containing Either<ApiError, User>serializedEitherSchema returns the plain transport shape:
// { value: { _tag: 'Left', error: { code, message } } }
// { value: { _tag: 'Right', value: { id, email } } }eitherSchema validates the same JSON, then hydrates the output into real
Left / Right instances:
if (hydrated.issues === undefined) {
// hydrated.value is Left<ApiError> | Right<User>
}Nested schemas are optional. Without them, yeet validates the outer
{ _tag, error | value } envelope and leaves the payload as unknown.
Scoped async work has a small extra vocabulary: domain errors, Aborted, and
Rejected. Use exitErrorSchema, serializedExitSchema, and exitSchema when
you want that whole outcome to be a portable value.
import { exitSchema, serializedExitSchema } from '@big-time/yeet'
const SerializedUserExit = serializedExitSchema({
error: ApiError,
value: User,
})
// inferred: SerializedExitSchema<ApiError, User>
const HydratedUserExit = exitSchema({
error: ApiError,
value: User,
})
// inferred: ExitSchema<ApiError, User>
// validates Left<ApiError | Aborted | Rejected> | Right<User>If no domain error schema is provided, the Exit schemas accept only yeet's
built-in Aborted and Rejected error payloads. Add reason or cause schemas
when those payloads need tighter validation too.
Standard Schema and Standard JSON Schema are separate interfaces. If a nested
schema only implements validation, validation still works; its JSON Schema slot
is emitted as {} because yeet refuses to invent facts in a nice hat.
For JSON Schema export with Zod, be explicit. Zod's documented API is
z.toJSONSchema(schema), with { io: 'input' } when you need the input side of
a transforming schema. Recent Zod versions may expose Standard JSON Schema
directly, but a tiny adapter keeps the README honest and lets you use Zod's
conversion options.
import * as z from 'zod'
import { serializedEitherSchema } from '@big-time/yeet'
type JsonSchema = Record<string, unknown>
type JsonSchemaOptions = {
readonly target: 'draft-2020-12' | 'draft-07' | 'openapi-3.0'
}
const withZodJsonSchema = <Schema extends z.ZodType>(
schema: Schema,
): typeof schema & {
readonly '~standard': (typeof schema)['~standard'] & {
readonly jsonSchema: {
readonly input: (options: JsonSchemaOptions) => JsonSchema
readonly output: (options: JsonSchemaOptions) => JsonSchema
}
}
} => ({
...schema,
'~standard': {
...schema['~standard'],
jsonSchema: {
input: (options: JsonSchemaOptions) =>
z.toJSONSchema(schema, { target: options.target, io: 'input' }),
output: (options: JsonSchemaOptions) =>
z.toJSONSchema(schema, { target: options.target }),
},
},
})
const SerializedUserResult = serializedEitherSchema({
error: withZodJsonSchema(ApiError),
value: withZodJsonSchema(User),
})
// inferred: SerializedEitherSchema<ApiError, User>
const jsonSchema = SerializedUserResult['~standard'].jsonSchema.output({
target: 'draft-2020-12',
})
// inferred: JsonSchemaTypeBox and TypeMap fit the same hole. Compile or adapt TypeBox schemas into
validators that expose ~standard, then pass them in:
import { Type } from '@sinclair/typebox'
import { Compile } from '@sinclair/typemap'
import { serializedEitherSchema } from '@big-time/yeet'
const ApiError = Compile(
Type.Object({
code: Type.String(),
message: Type.String(),
}),
)
const User = Compile(
Type.Object({
id: Type.String(),
email: Type.String({ format: 'email' }),
}),
)
const SerializedUserResult = serializedEitherSchema({
error: ApiError,
value: User,
})
// inferred: SerializedEitherSchema<ApiError, User>When the nested schemas implement Standard JSON Schema, yeet includes their
JSON Schema inside the exported Either envelope. That gives you a portable
shape for API docs, structured outputs, form builders, or any other bit of
software that enjoys receiving small rectangles of truth.
Yeet ships an optional unplugin optimizer. Your source stays the same; the
plugin looks for inline generator calls to either, validate, firstOf, and
collect that it can prove, then lowers them into plain early-return or
accumulator JavaScript. If it cannot prove the shape, it leaves the original
runtime call exactly where it found it.
No spooky action at a distance. Just a little stagehand moving furniture before the curtain rises.
// vite.config.ts
import yeet from '@big-time/yeet/unplugin/vite'
export default {
plugins: [yeet()],
}Adapter subpaths are available for Vite, Rollup, Webpack, Rspack, esbuild, and Bun:
| Tool | Import |
|---|---|
| Vite | @big-time/yeet/unplugin/vite |
| Rollup | @big-time/yeet/unplugin/rollup |
| Webpack | @big-time/yeet/unplugin/webpack |
| Rspack | @big-time/yeet/unplugin/rspack |
| esbuild | @big-time/yeet/unplugin/esbuild |
| Bun | @big-time/yeet/unplugin/bun |
The optimizer is binding-scoped, so aliased imports work while shadowed locals are politely ignored:
import { either as e } from '@big-time/yeet'
const result = e(function* () {
return yield* right(42)
})
// inferred: Either<never, number>It lowers these proven shapes:
- direct
yield* someEither()steps ineither - direct
yield* await somePromiseReturningEither()steps in asynceither, including bounded stream helpers likejson(body)andcollectText(stream) - direct
yield* nextsteps wherenextis aconstbinding fromfor await (const next of ndjson(...) | sse(...) | lines(...) | chunks(...)) - direct
yield* check(someEither())steps invalidate - direct
yield someEitherattempts infirstOfandcollect
On a local bun run bench:quick:node-style run, the transform shook out roughly
like this. The exact numbers will drift with hardware, runtime, warmup, and the
JIT's morning mood, but the shape is the useful part:
| Shape | Rough win |
|---|---|
either: single sync yield* success |
~31x |
either: two sync yield* successes |
~8.5x |
either: sync Left short-circuit |
~13x |
either: two async yield* await successes |
~5.9x |
either: async Left short-circuit |
~7.3x |
validate: two checks |
~10x |
firstOf: three attempts |
~10-11x |
Stream: yield* await json(body) |
~2.6x |
Stream: yield* next in ndjson / sse loops |
~1.2x |
collect: many yielded items |
~1x |
Tiny flows win hardest because the generator driver is most of the work. Once you are parsing JSON, walking many items, or calling real I/O, the plugin still removes the do-notation overhead, but the river is wider than the boat.
It bails on the abortable overload, escaped raise / check, this,
arguments, unproven indirect yield* values, non-const stream item
bindings, stream helpers from other modules, and expression positions where
hoisting would change evaluation. The runtime library remains the interpreter
underneath, as dependable as a man in a dark suit explaining how rain becomes a
river.
If you want to drive a generator yourself, fold and foldAsync accept a
Strategy:
type Strategy<Eff, Ret, Acc, R> = {
init: () => Acc
step: (eff: Eff, acc: Acc) => Step<Acc, R>
finish: (ret: Ret, acc: Acc) => R
}Everything higher-level in yeet is built from the same idea: initialize an
accumulator, handle each yielded value, and finish when the generator returns.
Most people will never need this. But it is there, because sometimes you want the keys to the old truck.
| API | Description |
|---|---|
left(error) |
Create a Left<E> |
right(value) |
Create a Right<A> |
isLeft(value) |
Narrow to Left |
isRight(value) |
Narrow to Right |
| API | Description |
|---|---|
either(fn) |
Short-circuiting sync or async generator runner |
either(signal, asyncFn) |
Abort-aware async runner; asyncFn receives a scoped AbortRaise |
capture(either) |
Preserve a Left as data inside either |
validate(fn) |
Accumulate every yielded error |
firstOf(fn) |
Return the first yielded Right |
collect(fn) |
Partition yielded values into errors and values |
| API | Description |
|---|---|
all(inputs) |
Run independent inputs concurrently and short-circuit by input order |
collectAll(inputs) |
Run independent inputs concurrently and partition all outcomes |
signal.fork(task) |
Start child work inside the current async either scope |
signal.forkAll(tasks) |
Run signal-aware child tasks and cancel siblings on first failure |
signal.forkRace(tasks) |
Return the first child outcome and abort losing tasks |
| API | Description |
|---|---|
ensure(condition, onFail) |
Return Right<void> or Left(onFail()) |
ensureNotNull(value, onNull) |
Unwrap a non-nullish value or return Left(onNull()) |
raise(error) |
Create a typed early return value |
raise(fn) |
Capture synchronous throw or promise rejection as Left<Rejected> |
raise(promiseLike) |
Capture promise rejection as Left<Rejected> |
aborted(reason) |
Create an Aborted error payload |
rejected(cause) |
Create a Rejected error payload |
| API | Description |
|---|---|
fromJSON(value) |
Hydrate trusted serialized JSON into Left / Right |
isSerializedEither(value) |
Detect yeet's strict JSON envelope |
serializedEitherSchema(options?) |
Standard Schema validator for serialized JSON |
eitherSchema(options?) |
Standard Schema validator that hydrates to Either |
exitErrorSchema(options?) |
Standard Schema validator for scoped Exit errors |
serializedExitSchema(options?) |
Standard Schema validator for serialized Exit JSON |
exitSchema(options?) |
Standard Schema validator that hydrates to Exit |
Import these from @big-time/yeet/stream.
| API | Description |
|---|---|
bytes(source, options?) |
Read a bounded byte source into Uint8Array |
text(source, options?) |
Read and UTF-8 decode a byte source |
json(source, options?) |
Read, decode, and JSON.parse a byte source |
chunks(source, options?) |
Yield byte chunks as Either values |
consume(source, options) |
Drain raw or Either streams without success allocations |
collectText(source, options?) |
Drain text deltas and join once |
lines(source, options?) |
Yield UTF-8 lines as Either values |
ndjson(source, options?) |
Yield parsed NDJSON records as Either values |
sse(source, options?) |
Yield server-sent events as Either values |
| API | Description |
|---|---|
fold(fn, strategy) |
Drive a sync generator with a custom strategy |
foldAsync(generator, strategy) |
Drive an async generator with a custom strategy |
Left and Right are small classes with Symbol.iterator, toJSON, and
Symbol.toPrimitive support. They work nicely with yield*, JSON
serialization, and straightforward tag checks.
A lot of Result libraries ask you to learn a second little programming language:
map, flatMap, andThen, pipe, tap, mapErr, orElse, and friends.
Good tools, many of them. But sometimes you already have the best control-flow
syntax available:
if (!user.active) return raise('Inactive' as const)
for (const item of items) yield processItem(item)
tryAnotherThing()yeet leans on generators to make that style type-safe. The errors flow through
the type system, the happy path reads top-to-bottom, and the runtime stays very
small.
Some things in life should be boring in precisely the right way.
There are Vitest benchmarks in src/*.bench.ts, plus a memory benchmark script.
bun run bench
bun run bench --target node
bun run bench --target bun
bun run bench:quick
bun run bench:quick:node
bun run bench:quick:bun
bun run bench:memorybun run bench --target node src/overhead.bench.ts
bun run bench --target bun --quick src/stream.bench.tsThese benchmarks are intentionally tiny and can be sensitive to runtime noise, JIT mood, and passing clouds. Treat them as directional, not holy scripture.
The current benchmark suite compares common either flows against
better-result, includes sync, async, short-circuit, validation, first success,
collection, plugin-transformed scenarios, and stream helpers against vanilla
async-iteration code.
For a rough overhead map, src/overhead.bench.ts compares the same core flows
four ways. These numbers are from a local quick run and are normalized per row
to vanilla try / throw / catch as 1x. Higher is faster:
| Scenario | Vanilla exceptions | better-result |
yeet |
yeet lowered |
|---|---|---|---|---|
| Sync two successes | 1x |
0.08x |
0.09x |
0.48x |
| Sync failure / short-circuit | 1x |
2.4x |
2.6x |
19x |
| Async two successes | 1x |
0.10x |
0.13x |
0.60x |
| Complex checkout success | 1x |
0.08x |
0.09x |
0.59x |
That is the honest bargain. On tiny success paths, plain JavaScript with
exceptions is the low-overhead baseline. On failure paths, exceptions pay for
their dramatic exit, while Either is just data walking through a door. Turn on
the unplugin and yeet gets much closer to the baseline on happy paths while
keeping the data-shaped exit on sad ones.
Stream helpers have a separate row because they do more than shuttle control flow. This compares a tiny hand-written parser against yeet's NDJSON helper, and then against the same yeet code after the build-time transform:
| Scenario | Manual parser | yeet stream |
yeet stream lowered |
|---|---|---|---|
| NDJSON stream success | 1x |
0.49x |
0.69x |
The stream helper still does real parsing, decoding, bounds, cleanup, and error-shaping work. The transform removes generator consumption overhead; it does not make a fully-featured stream parser vanish into a hand-rolled loop.
MIT