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).
throwin effect code fails the Effect withe, a typed error, exactly asEffect.fail(e)does.throwin anasyncfunction rejects its Promise, as in TypeScript.throwin any other.vibecode, or a call known to throw there outside thetryblock of atry/catchstatement, isEK0401. Code that throws lives in a.tsfile or amodulebody (VIP-40). See Plain code never throws. - Amended 2026-11-02: one rule for all TypeScript (Will). Only a callable declared in a
.ts,.jsor.d.tsfile, an npm package or amodulebody has a thrown type.@throws {T}means it throwsT, and no@throwsmeans it throwsunknown. Package-level special cases are gone. A.vibecallable throwsnever: nothing is inferred from a body, and a written@throwsor@rethrowsin a.vibefile isEK0406. - An
effectfunction may call a function that may throw only through an Effect wrapper. The wrapped call is then run withtry(VIP-5). The wrappers are Effect's own functions, which VIP-6 makes global:Effect.tryfor sync code, andtryPromisefor 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 isnever, and source argument expressions keep their separate contracts and order. - It calls a function that does not throw directly.
- 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
- Amended 2026-10-29: effectful callables. A function whose return type is wholly
Effect,StreamorLayercan'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,unknownand 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.parsethrowsSyntaxError,Math.maxis known not to throw, andOption.somethrowsnever. @rethrows <param>declares that a foreign call throws what its callback throws, as Swift'srethrowsdoes. VibeLang ships the rethrowing globals:map,forEach,filter,reduce,find,some,every,sort,Array.from.setTimeoutdoes not rethrow, and thePromiseexecutor's throws reject the Promise.- Implicit throws, such as reading a property of
undefinedor a stack overflow, are not tracked. They are defects, as inEffect.sync. So are receiver or environment state in a built-in, and the pattern protocol. - A function type or interface member with no
@throwsadmits onlynever. A call throughanyorFunctionthrowsunknown, 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.
@throwsand@rethrowsattach 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.tsglobals and effect 4.1.1's non-Effect exports are generated:@throws {never}only when the source has nothrowreachable from well-typed input, a named class when one is known (SchemaError), otherwiseunknown. 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.tryandEffect.tryPromiseadmit a throwing thunk.sync,promise,callbackandsuspendtakeLazyArg, 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:
throwsin the type,tryat each call,rethrows. - Koka: the inferred
exneffect. - Java's checked exceptions, the cautionary tale: they were declared by hand.
- TypeScript's long-standing request for
throwstypes.
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.mapwith 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.