Skip to content
LogoLogo

VIP-4: throw, and calls that may throw

Status: accepted by Will, 2026-10-28.

Decision

// parse.ts: TypeScript may throw
/** @throws {Error} */
export function parse(s: string) { if (!s) throw new Error("empty"); return s }
 
// load.vibe
import { parse } from "./parse.ts"
function add(a, b) { return a + b }                                 // a plain function never throws
function bad(s) { if (!s) throw new Error("empty"); return s }      // EK0401
async function get(url) { return Promise.reject(new Error("no")) }  // allowed, as in TypeScript
 
effect function load(id) {
  if (!user) throw new NotFound(id)            // the same as failing with Effect.fail(new NotFound(id))
  add(1, 2)                                    // ok: doesn't throw
  parse(s)                                     // expect-error: EK0403; may throw, so wrap it and run it with try
  try Effect.try(() => parse(s))               // ok: Effect.try (a reserved word, so not a global)
  try tryPromise(() => queryDb(q))             // a Promise is always wrapped (VIP-6 global)
}
  • Amended 2026-10-31: plain functions never throw (Will). throw in effect code fails the Effect with e, a typed error, exactly as Effect.fail(e) does. throw in an async function rejects its Promise, as in TypeScript. throw in any other .vibe code, or a call known to throw there outside the try block of a try/catch statement, is EK0401. Code that throws lives in a .ts file or a module body (VIP-40). See Plain code never throws.
  • Amended 2026-11-02: one rule for all TypeScript (Will). Only a callable declared in a .ts, .js or .d.ts file, an npm package or a module body has a thrown type. @throws {T} means it throws T, and no @throws means it throws unknown. Package-level special cases are gone. A .vibe callable throws never: nothing is inferred from a body, and a written @throws or @rethrows in a .vibe file is EK0406.
  • An effect function may call a function that may throw only through an Effect wrapper. The wrapped call is then run with try (VIP-5). The wrappers are Effect's own functions, which VIP-6 makes global: Effect.try for sync code, and tryPromise for a Promise.
    • It always wraps a Promise, whether or not the Promise can reject. Tagged-template invocations use the same selected-signature Promise/thenable result test and explicit bridge. Runtime import() uses the same bridge: its own synchronous thrown type is never, and source argument expressions keep their separate contracts and order.
    • It calls a function that does not throw directly.
  • Amended 2026-10-29: effectful callables. A function whose return type is wholly Effect, Stream or Layer can't throw, and calling it is never throw-tracked: its failures are its Effect's error channel (Will: "@throws only applies to non effectful functions[;] an effectful function literally cannot throw"). any, unknown and a union with a non-effectful member are not effectful. Generic signatures are classified before type-argument substitution, through their constraints.
  • JavaScript's globals and Effect's exports carry no tags, so VibeLang ships annotations for them: JSON.parse throws SyntaxError, Math.max is known not to throw, and Option.some throws never.
  • @rethrows <param> declares that a foreign call throws what its callback throws, as Swift's rethrows does. VibeLang ships the rethrowing globals: map, forEach, filter, reduce, find, some, every, sort, Array.from. setTimeout does not rethrow, and the Promise executor's throws reject the Promise.
  • Implicit throws, such as reading a property of undefined or a stack overflow, are not tracked. They are defects, as in Effect.sync. So are receiver or environment state in a built-in, and the pattern protocol.
  • A function type or interface member with no @throws admits only never. A call through any or Function throws unknown, and a union of callables throws the union of its alternatives. new X() is tracked; getters and setters are not.
  • Amended 2026-10-29: contracts sit on call signatures. @throws and @rethrows attach to a call signature, as TypeScript attaches JSDoc to each overload; a returned function's contract sits on its own signature. Declaration output writes the contract on every emitted call signature. Decided by the agent on 2026-10-29 under Will's rule "when I state a principle, the agent derives the concrete rule and acts; I can override later" (decided detail 38).
  • Amended 2026-10-29: generated annotations. The remaining annotations of lib.d.ts globals and effect 4.1.1's non-Effect exports are generated: @throws {never} only when the source has no throw reachable from well-typed input, a named class when one is known (SchemaError), otherwise unknown. Decided by the agent on 2026-10-29 under Will's rule "when I state a principle, the agent derives the concrete rule and acts; I can override later" (decided detail 40).
  • Amended 2026-10-29: only two wrappers take a throwing thunk. Only Effect.try and Effect.tryPromise admit a throwing thunk. sync, promise, callback and suspend take LazyArg, which has no @throws, so a throwing thunk there is a compile error with a quick fix to the matching wrapper. Decided by the agent on 2026-10-29 under Will's rule "when I state a principle, the agent derives the concrete rule and acts; I can override later" (decided detail 41).

Corrected 2026-10-29: in effect 4.1.1, Schema.decodeUnknownSync(schema) only builds a decoder and throws nothing; the decoder it returns is what throws SchemaError. The annotation amendment above means that returned decoder (found by the round-4 review).

Prior art

  • Swift: throws in the type, try at each call, rethrows.
  • Koka: the inferred exn effect.
  • Java's checked exceptions, the cautionary tale: they were declared by hand.
  • TypeScript's long-standing request for throws types.

Will's words

"Ok we can allow throw and we can allow Promise.reject. But here is the catch. Our type system pays attention to if a function is known to throw. And we do NOT allow you to call a function that may throw without properly wrapping it in an effect. Promises you have to wrap always even if they don't throw but sync functions that do not throw are allowed to be called without wrapping"

"correct throw in an effect function is the same as returning Effect.fail(error)"

"this is exactly right but later we will make try a keyword instead of Effect.try(). … Any javascript globals like JSON.parse must have no annotation. We add this annotation via @throws {ErrorType} in jsdoc. [Anything] that doesn't have this automatically throws unknown. But for globals like Math.max we can go through and self annotate it ourselves so that this language experience is great."

"if you are in normal vibelang you can't throw. You can throw in ts. You can throw in a ts module using module syntax. You can throw in a ts file you import into vibelang. If you throw in vibelang itself in a sync function that's an error." (2026-10-31)

"A typescript function without a @throws has an unknown error type that it throws. This is literally how typescript already works. Trusted packages does not make sense as a concept nor does it make any sense why you are even thinking about treating npm packages any different than a normal .ts file." (2026-11-02)

Updated by VIP-5 and VIP-6 (2026-10-28): wrapped calls run with try, and the wrappers are Effect's own functions.

Corrected 2026-10-29: the examples call queryDb, a Promise-returning function imported from a .ts module, because VIP-33 forbids platform calls such as fetch anywhere in a .vibe file.

Footnote: old VibeLang

Old VibeLang is a reference only; nothing in it is part of the language unless a VIP adds it. For implementers, this VIP replaces:

R8 (2026-10-28, "no throw in .vibe"), and the form rules that made throw an Effect form (D550).

Later VIPs

  • "Effectify the universe": replace non-Effect functions such as Array.map with Effect-aware versions. Will: "a very complex and controversial one that we might not even do and won't be in our initial release". It will be proposed, but not accepted right away.

Amendment, 2026-11-02: all TypeScript follows one throws rule

Will: “A typescript function without a @throws has an unknown error type that it throws. This is literally how typescript already works. Trusted packages does not make sense as a concept nor does it make any sense why you are even thinking about treating npm packages any different than a normal .ts file.”

An npm package's functions follow exactly the rule a local .ts file's do: no @throws means it may throw unknown, and @throws means what it says. Package names, re-exports, returned values and compiler inventories grant no different thrown-type contract. Effectful return types and explicit wrapper-thunk and intrinsic global annotations retain their ordinary rules. This supersedes an earlier reading of the Effect-annotation inventory as implicit library signature annotations: an npm signature needs its own annotation.

Retirement of the empty legacy trust wrapper

The historical trusted(f, []) runtime wrapper is not an accepted VIP form. It is retired with EK0407, which names vibelang --fix. vibelang --fix rewrites each private direct synchronous call in an explicit Effect body to the identity-mapped Effect.try / Effect.orDie bridge over f, captured at the original declaration; a rewrite that can't preserve types, behavior and declaration output refuses at that site. A reviewed TypeScript helper with @throws {never} is another replacement. Calls with listed failure classes are outside this retirement.