Thanks to visit codestin.com
Credit goes to github.com

Skip to content

organize/yeet

Repository files navigation

yeet

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.

But I'm Scared

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 signal to 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.

Contents

Install

npm install @big-time/yeet
pnpm add @big-time/yeet
yarn add @big-time/yeet
bun add @big-time/yeet

yeet is ESM-only, ships TypeScript declarations, and has zero runtime dependencies.

Quick Start

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.

Core Model

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.

Synchronous Flows

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.

Guards

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 Flows

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[] }
// >

Capturing Rejections

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: Config

Use the direct form when you already have a promise:

const response = yield * (await raise(fetch('/api/user')))
// inferred: Response

Use 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: Payload

Cancellation

Pass 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 = RaiseContext

Use 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.

Scoped Forks

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.

Streams And Bytes

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.

Bounded Bodies

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.

AI Text Deltas

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>>

Structured Streams

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.

Composition Helpers

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 }

Capture Instead Of Short-Circuit

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.

Concurrent Inputs With all

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 }>

Partition Concurrent Inputs With collectAll

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)[]

Accumulate Errors With validate

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.

Try The First Success With firstOf

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 Results With collect

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.

Serialization And Schemas

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: string

toJSON() 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 }>

Hydrating Trusted JSON

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.

Validating Untrusted JSON

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.

Exporting JSON Schema

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: JsonSchema

TypeBox 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.

Build-Time Optimizer

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 in either
  • direct yield* await somePromiseReturningEither() steps in async either, including bounded stream helpers like json(body) and collectText(stream)
  • direct yield* next steps where next is a const binding from for await (const next of ndjson(...) | sse(...) | lines(...) | chunks(...))
  • direct yield* check(someEither()) steps in validate
  • direct yield someEither attempts in firstOf and collect

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.

Low-Level Folding

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 Reference

Core

API Description
left(error) Create a Left<E>
right(value) Create a Right<A>
isLeft(value) Narrow to Left
isRight(value) Narrow to Right

Generator Runners

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

Concurrency

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

Guards And Async Helpers

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

Serialization And Schemas

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

Streams And Bytes

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

Lower-Level Machinery

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.

Why This Exists

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.

Benchmarks

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:memory
bun run bench --target node src/overhead.bench.ts
bun run bench --target bun --quick src/stream.bench.ts

These 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.

License

MIT

About

tiny, zero-dependency, generator-based typed errors for TypeScript

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors