Skip to content

organize/yeet

Repository files navigation

yeet

Some say error handling is hard. They are not entirely wrong. But they have, perhaps, been using tools that make it feel like carrying furniture through a revolving door.

yeet is a tiny, dependency-free Either library for TypeScript. It gives you typed Left / Right values, generator-based do-notation, async support, and a few practical helpers for validation and fallback flows.

No runtime dependencies. No method-chain cathedral. No pipe operator pilgrimage.

Just ordinary JavaScript control flow, with TypeScript quietly keeping score.

import { either, left, right, type Either } from '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 }
})

// Either<"UserNotFound" | "Inactive" | "DbError", { user: User; orders: Order[] }>

Install

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

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

The Idea

An Either<E, A> is one of two things:

left(error) // Left<E>
right(value) // Right<A>

Inside either(...), you can unwrap a Right with yield*. If the value is a Left, the whole computation short-circuits and returns that Left.

const result = either(function* () {
  const value = yield* right(42) // value is 42
  yield* left('Nope') // stops here
  return value
}) // Either<'Nope', 42>

Returning raise(error) is the typed early-exit move:

const result = either(function* (raise) {
  const user = yield* getUser(id)
  if (!user.active) return raise('Inactive' as const)
  return user
}) // Either<'UserNotFound' | 'Inactive', User>

There are no annotations in that function body. The error union is inferred from the things you yield and raise.

Sync

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

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.

Async

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

Promises and thenables can go through raise(promiseLike). Rejections become Left<Rejected> instead of escaping as thrown exceptions.

import { either } from '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
})

If starting the operation can throw synchronously, pass a function instead. raise(fn) uses Promise.try, so both synchronous throws and rejected promises become Left<Rejected>.

const config = yield * (await raise(() => JSON.parse(readConfigFile())))

Concurrent All

Normal yield* await code is sequential. That is usually what you want, but sometimes two independent things should begin their little journeys at the same time.

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

import { all, collectAll, either } from 'yeet'

const result = await either(async function* () {
  const [user, settings] = yield* await all([fetchUser(id), fetchSettings(id)])

  return { user, settings }
})

The result is tuple-shaped, so each success keeps its own type:

const result = await all([
  right(1),
  Promise.resolve(right('two')),
  () => right(true),
])

// 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")
])

// Left("SlowFailed")

If the work itself can throw while starting, use thunks:

const result = await all([() => parseConfigFile(), () => fetchSettings()])

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

collectAll is the sibling that does not short-circuit. It runs the same inputs and partitions everything:

const { values, errors } = await collectAll(
  ids.map((id) => () => fetchUser(id)),
)

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. That is what capture is for.

import { capture, either } from '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)
})

capture(either) returns Right<Either<E, A>>, which means the outer either(...) unwraps the Right and hands you the original Either as an ordinary value. A small trapdoor, tastefully installed.

Serialization

Left and Right serialize to small tagged JSON objects. Nothing clever is hiding under the floorboards.

JSON.stringify(left('Nope'))
// {"_tag":"Left","error":"Nope"}

JSON.stringify(right({ id: 'user-1' }))
// {"_tag":"Right","value":{"id":"user-1"}}

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

For trusted values that already have the serialized shape, fromJSON hydrates them back into Left / Right instances:

import { fromJSON, isSerializedEither, type SerializedEither } from 'yeet'

type User = { id: string }

const parsed = JSON.parse(json) as SerializedEither<string, User>
const result = fromJSON(parsed)

isSerializedEither(value) is available when you only need to detect yeet's strict outer envelope. It does not validate nested payloads; that is what the schemas below 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 '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,
})

const hydratedUserResult = eitherSchema({
  error: ApiError,
  value: User,
})

const parsed = await SerializedUserResult['~standard'].validate(
  JSON.parse(json),
)
const hydrated = await hydratedUserResult['~standard'].validate(
  JSON.parse(json),
)

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

For JSON Schema export, 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,
  type StandardJSONSchemaOptions,
  type StandardJSONSchemaV1,
  type StandardSchemaV1,
} from 'yeet'

const withZodJsonSchema = <Schema extends z.ZodType>(
  schema: Schema,
): StandardSchemaV1<z.input<Schema>, z.output<Schema>> &
  StandardJSONSchemaV1<z.input<Schema>, z.output<Schema>> => ({
  '~standard': {
    ...schema['~standard'],
    jsonSchema: {
      input: (options: StandardJSONSchemaOptions) =>
        z.toJSONSchema(schema, { target: options.target, io: 'input' }),
      output: (options: StandardJSONSchemaOptions) =>
        z.toJSONSchema(schema, { target: options.target }),
    },
  },
})

const SerializedUserResult = serializedEitherSchema({
  error: withZodJsonSchema(ApiError),
  value: withZodJsonSchema(User),
})

const jsonSchema = SerializedUserResult['~standard'].jsonSchema.output({
  target: 'draft-2020-12',
})

TypeBox and TypeMap fit the same hole. Compile or adapt the TypeBox schemas into validators that expose ~standard, then pass them in:

import { Type } from '@sinclair/typebox'
import { Compile } from '@sinclair/typemap'
import { serializedEitherSchema } from '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,
})

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.

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.

serializedEitherSchema({ error: ApiError, value: User })
// validates: unknown -> SerializedEither<ApiError, User>

eitherSchema({ error: ApiError, value: User })
// validates: unknown -> Either<ApiError, User>

Nested schemas are optional. Without them, yeet validates the outer { _tag, error | value } envelope and leaves the payload as unknown.

Small Guards

ensure and ensureNotNull cover common checks without making you write tiny one-off Either factories.

import { either, ensure, ensureNotNull } from '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
})

Accumulate Errors

Sometimes the first error is not enough. validate runs every check and returns all failures as Left<E[]>.

import { validate, left, right, type Either } from '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 }
})

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

firstOf tries yielded Eithers in order and returns the first Right. If they all fail, it returns every error.

import { firstOf } from 'yeet'

const user = firstOf(function* () {
  yield getUserFromCache(id)
  yield getUserFromReplica(id)
  yield getUserFromPrimary(id)
})

// Either<Error[], User>

Collect Results

collect partitions every yielded value into successes and failures.

import { collect } from 'yeet'

const { values, errors } = collect(function* () {
  for (const item of items) {
    yield processItem(item)
  }
})

No short-circuiting. No judgment. Just two arrays, standing there in the light.

Manual 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

// Core
left(error)
right(value)
isLeft(value)
isRight(value)

// Generator runners
either(fn)
capture(either)
validate(fn)
firstOf(fn)
collect(fn)
all(inputs)
collectAll(inputs)

// Serialization and schemas
fromJSON(value)
isSerializedEither(value)
serializedEitherSchema(options?)
eitherSchema(options?)

// Guards and async helpers
ensure(condition, onFail)
ensureNotNull(value, onNull)
raise(error)
raise(fn)
raise(promiseLike)

// Lower-level machinery
fold(fn, strategy)
foldAsync(generator, 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:quick
bun run bench:memory

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, and includes sync, async, short-circuit, validation, first success, and collection scenarios.

License

MIT

About

tiny, zero-dependency, generator-based Result type for TypeScript

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors