Pattern matching
Source: VIP-7.
Syntax and meaning
// compiler-gap: pending VIP-7
effect function label(d: { _tag: "Sent"; tracking: string } | { _tag: "Waiting" }) {
const tracking = "outer"
return match (d) {
when { _tag: "Sent", let tracking }: try Effect.succeed(tracking);
default: tracking;
}
}import { Effect } from "effect"
function label(d: { _tag: "Sent"; tracking: string } | { _tag: "Waiting" }) {
return Effect.gen(function* () {
const tracking = "outer"
const subject = d
if (subject._tag === "Sent" && "tracking" in subject) {
const tracking = subject.tracking
return yield* Effect.succeed(tracking)
}
return tracking
})
}match, when, is, patterns, guards, and, or, not and default follow the vendored TC39 snapshot: commit ba73ffe42f30f0cf56ece28bebdc28f955f2a52e, dated 2025-10-03, with the reconciliations listed there. VibeLang moves to a newer snapshot deliberately, never automatically. The full proposal is in the language, including regular-expression patterns, relational patterns and async match, with the class adaptations below.
The first label counterpart uses a short object test because its subject type guarantees an object with _tag. For an arbitrary subject, the pinned algorithm first rejects non-objects (functions are objects too), then checks property presence before reading it; the short form is not a general lowering.
MatchExpression is a primary expression. Its arm grammar is MatchBody, MatchArm and ArmBody: the pinned draft with VIP-7's when prefix and VIP-5's throw arm body. At least one arm is required; default appears at most once, last; guards belong to MatchPattern (P and if (condition)). Separate arms with ;, including the final arm; a missing separator is EK0701 (quick fix: insert ;). A newline alone is not a separator. The reconciliations are: relational patterns use the draft specification; regex patterns accept both literals and RegExp values; async match uses the explainer's Promise-producing sketch; custom matchers receive (subject, { matchType }), with matchType either "boolean" or "extractor"; the RegExp.prototype matcher uses the draft specification, emulated without modifying globals; built-in and primitive constructor tests are in Class patterns.
Invalid match/is grammar and pattern-specific early errors use EK0707 when no more specific diagnostic is assigned. The message identifies the rejected form and a valid repair. Do not add it to a specific diagnostic for the same rejection, including arm separation, matcher admission, arity lint, ordinary-class extraction, parameter-home bindings, outward async-match jumps, exhaustiveness or service patterns. Independent native lexical, type and name errors, including errors in valid embedded expressions, retain TypeScript's diagnostics; malformed native templates do not become EK0707 merely by appearing in a match.
const bad = match (s) { when `tag-${kind}`: 1; default: 0; } // EK0707
const good = match (s) { when let v and if (v === `tag-${kind}`): 1; default: 0; }
const nativeError = match (s) { when let v and if (missingName): 1; default: 0; } // TS2304The rejected primitive pattern has no runtime counterpart. The guard repair retains ordinary template evaluation:
import { Effect, Exit } from "effect"
function patternGuardRepair(s: unknown, kind: string) {
const v = s
return v === `tag-${kind}` ? 1 : 0
}
function independentNativeError() {
// @ts-expect-error TS2304: this remains a native name error
return missingName ? 1 : 0
}
const patternSyntaxExit = Effect.runSyncExit(Effect.sync(() => [
patternGuardRepair("tag-A", "A"),
patternGuardRepair("tag-B", "A"),
patternGuardRepair(Symbol("tag-A"), "A")
]))
console.log(patternSyntaxExit) // Success([1, 0, 0])A primitive template pattern is a NoSubstitutionTemplate, such as `tag-A`. An interpolated template in pattern position is EK0707; ordinary interpolation remains valid in guards and arm expressions. This follows the pinned formal grammar where the explainer leaves interpolation unsettled. A no-substitution template uses general SameValueZero: equal strings match and every non-string subject fails without coercion, as for a string literal.
// compiler-gap: pending VIP-7
function templateLabel(s: unknown, kind: string) {
return match (s) {
when `tag-A`: "literal";
when let v and if (v === `tag-${kind}`): "guard";
default: "miss";
}
}
// when `tag-${kind}`: is a syntax error; use the guard above.import { Effect } from "effect"
function templateLabel(s: unknown, kind: string) {
if (s === "tag-A") return "literal"
if (s === `tag-${kind}`) return "guard"
return "miss"
}
const templateExit = Effect.runSyncExit(Effect.sync(() => [
templateLabel("tag-A", "B"), templateLabel("tag-B", "B"), templateLabel(Symbol("tag-A"), "B"),
])) // Success(["literal", "guard", "miss"])Synchronous match evaluates its subject once and tests arms in order. It introduces no function or Effect boundary: its ordered tests and selected arm stay in the enclosing activation, so an arm's try becomes yield* in the enclosing Effect generator, as above. Only the selected arm body runs; guards run during arm testing and may have side effects. When no arm matches and there is no default, it throws new TypeError(), with an empty message: the pinned snapshot says only "a newly created TypeError", and no user code runs. In effect code that throw is a defect, not a failure.
Match cache. One CreateMatchCache per match (and per is test) caches property presence, property reads, Symbol.iterator lookup, iterators and pulled values per subject across arms, as the pinned draft specifies. Later arms reuse them: a cached pattern getter runs at most once and an iterable subject is iterated once. Object-rest copying is separate: it follows ordinary CopyDataProperties and may read the same getter again. The short object and fixed-pattern counterparts in this chapter omit cache machinery for subjects with plain data properties; they are not general lowerings for getters or iterators.
The pinned matching operations obey these behavioral invariants, with the draft errata recorded in the snapshot. Ordinary expression reads use ECMAScript GetValue; iterator lookup preserves GetV receivers, and object rest keeps CopyDataProperties.
| Rule | Required behavior |
|---|---|
| Object properties | Test properties left to right in source order, stopping at the first failure. For each property retain key evaluation, presence check, cached value read, sub-pattern and binding initialization in order; copy rest afterward. |
| Guard and selected expressions | Read a guard expression's value once before Boolean conversion. Read every selected/default arm expression's value once before iterator finishing. Getter calls, TDZ errors and abrupt completions occur at that read, never on an internal Reference Record or after cleanup. |
| Object rest | Use ordinary own-key, enumerability and value reads outside the match-specific read cache. A rest copy can re-read a previously cached getter; do not freeze its keys or descriptors in an earlier arm. |
| Array-pattern admission | null and undefined fail. Other subjects use cached Symbol.iterator lookup with GetV semantics: retain the original subject as receiver for both an inherited getter and the iterator call, including primitive strings. Missing/noncallable methods fail the pattern. Throwing accessors/calls and malformed protocol results retain their implicit throws. |
| Iterator records | Array and successful extractor list matching, and cleanup, use the same private cached Iterator Records, not user iterables. Successful custom extractor results must be objects. Iterator registration/closing stays in insertion order and skips empty registry slots; bookkeeping never reads user properties named Value or IteratorsToClose. Native iterator acquisition, saved next, result-object checks, done/value reads and abrupt-completion handling are preserved; an iterator marked done by an iteration error is not closed again. |
| Elisions | Each Elision consumes its full number of commas, excluding a list-separator comma. Every skipped position must exist. Leading, internal and trailing elisions advance cached reads and list cardinality by that width. |
| Optional elements | An absent optional element consumes zero values, leaves the iterator done and the actual index unchanged, and does not run its sub-pattern or initialize its bindings. Repeated missing optionals remain absent. A present undefined is a value and must satisfy its sub-pattern; no absent-binding default is invented. |
| Inclusive relations | On the admitted string, number or bigint subject, <= bound and >= bound mean ordinary subject <= bound and subject >= bound, with once-only bound evaluation and native coercion order. Unordered/NaN comparisons fail both; other subject types fail before bound evaluation. The draft's reversed inclusive bounds are not adopted. |
// compiler-gap: pending VIP-7
effect function evaluationWitness() {
let reads = 0
const restSubject = { get value() { return ++reads } }
const copied = match (restSubject) { when { value: 0 }: 0; when { ...let rest }: rest.value; }
const short = match ([9, 3]) { when [,,3]: "hit"; default: "miss"; }
const full = match ([9, 8, 3]) { when [,,3]: "hit"; default: "miss"; }
const absent = match ([]) { when [1?, 2?]: "hit"; default: "miss"; }
const present = match ([undefined]) { when [1?]: "hit"; default: "miss"; }
const primitive = match ("ab") { when ["a", "b"]: "word"; default: "other"; }
const flag = false
const guarded = match (true) { when true and if (flag): "hit"; default: "miss"; }
const lower = match (1) { when <= 2: "small"; default: "large"; }
const upper = match (1) { when >= 2: "large"; default: "small"; }
return { copied, reads, short, full, absent, present, primitive, guarded, lower, upper }
}
// Success({ copied: 2, reads: 2, short: "miss", full: "hit", absent: "hit", present: "miss", primitive: "word", guarded: "miss", lower: "small", upper: "small" })The following counterpart witnesses use their literal arrays and plain objects; they are not a general matcher lowering.
import { Effect } from "effect"
function evaluationWitness() {
let reads = 0
const restSubject = { get value() { return ++reads } }
let copied = 0
if (restSubject.value !== 0) { const { ...rest } = restSubject; copied = rest.value }
const elided = (xs: readonly number[]) => { const [, , third] = xs; return xs.length === 3 && third === 3 ? "hit" : "miss" }
const flag = false
const guarded = flag ? "hit" : "miss"
const optional = (xs: readonly unknown[]) => xs.length === 0 || (xs.length === 1 && xs[0] === 1) ? "hit" : "miss"
return { copied, reads, short: elided([9, 3]), full: elided([9, 8, 3]), absent: optional([]), present: optional([undefined]), primitive: [..."ab"].join() === "a,b" ? "word" : "other", guarded, lower: 1 <= 2 ? "small" : "large", upper: 1 >= 2 ? "large" : "small" }
}
const evaluationExit = Effect.runSyncExit(Effect.sync(evaluationWitness))
// Success({ copied: 2, reads: 2, short: "miss", full: "hit", absent: "hit", present: "miss", primitive: "word", guarded: "miss", lower: "small", upper: "small" })
const sourceOrder: string[] = []
const ordered = { get first() { sourceOrder.push("first"); return sourceOrder.length }, get second() { sourceOrder.push("second"); return sourceOrder.length } }
const sourceOrderExit = Effect.runSyncExit(Effect.sync(() => ordered.first === 1 && ordered.second === 2)) // Success(true); sourceOrder: ["first", "second"]
function armBeforeClose(log: string[], answer: { readonly value: number }) {
function* values() { try { yield 1; yield 2 } finally { log.push("close") } }
for (const value of values()) return answer.value
return 0
}
const armOrder: string[] = []
const armValueExit = Effect.runSyncExit(Effect.sync(() => armBeforeClose(armOrder, { get value() { armOrder.push("arm"); return 7 } })))
// Success(7); armOrder: ["arm", "close"]Leaving a match. However a match or an is test ends, FinishMatch closes every unfinished cached iterator, in draft order, before control goes on, as the end of a for…of body closes its iterator:
| The arm, guard or test ends with | What happens |
|---|---|
| a value | the iterators close, then the match gives the value |
return, break or continue, through do or a generator's return() | the iterators close, then the jump goes on |
a JavaScript throw, the no-match TypeError included | the iterators close, then the throw goes on |
| an error from closing | it replaces a value or a jump, as in for…of. With a pending throw, or a second closing error, the match throws an AggregateError whose errors lists the pending throw and then the closing errors, where for…of keeps the pending throw alone |
in effect code, a try that fails, dies or is interrupted, or a throw | nothing closes: the generator is abandoned |
The pinned draft's FinishMatch appends every abrupt completion to its errors, so read literally it turns return 7 into throw 7. Its own notes intend otherwise, and VibeLang reads that step as "a throw completion" (the snapshot's reconciliations). The rule is the for…of rule because a jump out of an arm is the same completion as a jump out of a loop body:
// compiler-gap: pending VIP-7
function* numbers(log: string[]) { try { yield 1; yield 2 } finally { log.push("closed") } }
function first(log: string[]) {
const v = match (numbers(log)) { when [let a, ...]: do { return a }; default: 0; }
return -1
}
effect function answer() {
const ignored = match (true) { default: do { return 7 }; }
return 0
}
effect function typed(log: string[]) {
return match (numbers(log)) { when [let a, ...]: do { throw "bad" }; default: 0; }
}
effect function implicit(log: string[], o: { value: number }) {
return match (numbers(log)) { when [let a, ...]: o.value; default: 0; }
}| Call | Exit | log |
|---|---|---|
first(log) | returns 1 | ["closed"], as for (const a of numbers(log)) return a |
answer() | Success(7) | |
typed(log) | Failure("bad") | []: the generator is abandoned |
implicit(log, o), the getter o.value throws "t" | Die("t") | ["closed"]: a JavaScript throw unwinds the generator |
import { Effect, Exit } from "effect"
function* numbers(log: string[]) { try { yield 1; yield 2 } finally { log.push("closed") } }
function first(log: string[]) {
for (const a of numbers(log)) return a
return -1
}
function typed(log: string[]) {
return Effect.gen(function* () {
for (const a of numbers(log)) return yield* Effect.fail("bad")
return 0
})
}
function implicit(log: string[], o: { value: number }) {
return Effect.gen(function* () {
for (const a of numbers(log)) return o.value
return 0
})
}
const firstLog: string[] = []
if (first(firstLog) !== 1 || firstLog.join() !== "closed") throw new Error("return lost its close")
const typedLog: string[] = []
const typedExit = Effect.runSyncExit(typed(typedLog))
if (!Exit.isFailure(typedExit) || typedLog.length !== 0) throw new Error("a failure closed the iterator")
const implicitLog: string[] = []
const bad = { get value(): number { throw "t" } }
const implicitExit = Effect.runSyncExit(implicit(implicitLog, bad))
if (!Exit.isFailure(implicitExit) || implicitLog.join() !== "closed") throw new Error("a throw skipped the close")// compiler-gap: pending VIP-7
function* two() { yield 1; yield 2 }
function pairs(g: Generator<number, void, unknown>) {
return match (g) { when [let a]: a; when [let a, let b]: a + b; default: 0; }
}
pairs(two()) // 3: the second arm reuses 1 and 2For the fixed two() generator above, the two yielded values give this native witness. The general caching and closing behavior is the rule above; the witness does not prescribe a matcher lowering.
import { Effect } from "effect"
function* two() { yield 1; yield 2 }
function pairCacheWitness() {
const values = [...two()]
const iterator: Iterator<number, undefined> = values[Symbol.iterator]()
const a = iterator.next()
const b = iterator.next()
return !a.done && !b.done && iterator.next().done ? a.value + b.value : 0
}
const pairCacheExit = Effect.runSyncExit(Effect.sync(pairCacheWitness)) // Success(3)- In an object pattern, a bare key such as
{ tracking }only tests that the property exists. Binding it takeslet tracking, shorthand fortracking: let tracking(VIP-16). Replacinglet trackingwithtrackingabove would return"outer", not the field. VibeLang does not adopt the draft's disputed early error rejecting aMatchPropertyname coverable byBindingIdentifier, either as a bare presence key or before:. Identifier keys such as_tag:remain valid; the separate__proto__pattern-key restriction and unrelated early errors remain. - Prefix
notwins whenever the same token sequence can also be a member/extractor rooted at an identifiernot. Parsing never consults a local binding or its type, and does not retry that identifier parse to evade an early error. Thusnot(let x)negates a parenthesized binding pattern andnot[0]negates an array pattern;helpers.not(let x)remains a qualified extractor.notstays an ordinary name outside the prefix collision and in property/binding-name positions. - Pattern bindings use
letorconst;when var x:isEK0107, asvaris everywhere in.vibe. - An arm's body may be
throw e, as VIP-5's example writes it. It means whatthrowmeans where the arm stands (Failures): in effect code it fails the Effect, in anasyncfunction or anasync matchit rejects the Promise, and in any other.vibecode it isEK0401. Nested source callables keep their own contexts. - No-match
TypeError, implicit matcher reads/calls and protocol checks are implicit throws: never tracked, and defects in effect code. Written calls in subjects, guards, arms and call-only predicates are tracked normally (Thrown types).
// compiler-gap: pending VIP-4, VIP-7
function plainArm(b: boolean) {
return match (b) { when true: 1; default: throw new Error("missing"); } // EK0401
}
effect function effectArm(b: boolean) {
return match (b) { when true: 1; default: throw new Error("missing"); }
}
function asyncArm(b: boolean) {
return async match (b) { when true: 1; default: throw new Error("missing"); }
} // asyncArm(false) rejects its Promiseimport { Effect } from "effect"
function effectArm(b: boolean) {
return Effect.gen(function* () {
if (b === true) return 1
return yield* Effect.fail(new Error("missing"))
})
}
function asyncArm(b: boolean) {
return (async () => {
if (b === true) return 1
throw new Error("missing")
})()
}The empty array pattern uses ArrayPattern with its one nullable-list derivation. It matches an empty iterable, including an empty string, and is not a wildcard or an array-only test. Every array pattern and extractor list receives a cached Iterator Record; its finishing paths never receive the original subject instead of that record.
// compiler-gap: pending VIP-7
function empty(xs: Iterable<unknown>) {
return match (xs) { when []: "empty"; default: "not empty"; }
}
function not(s: unknown): [unknown] { return [s] }
const helpers = { not }
match (5) { when not(let x): "hit"; default: "miss"; } // "miss"; the local function is not called
match (5) { when helpers.not(let x): "hit"; default: "miss"; } // "hit"
match ([0]) { when not[0]: "other"; default: "zero"; } // "zero"import { Effect } from "effect"
function empty(xs: Iterable<unknown>) {
for (const value of xs) return "not empty"
return "empty"
}
const emptyPatternExit = Effect.runSyncExit(Effect.sync(() => [empty([]), empty(""), empty([1])]))
// Success(["empty", "empty", "not empty"])A match in a destructuring or parameter default follows Destructuring and parameter defaults. A match inside a larger expression follows the shared evaluation-order rule. eval and arguments are compile errors anywhere in .vibe, including subjects, guards and arms (Legacy JavaScript; VIP-1, amended).
// compiler-gap: pending VIP-7
effect function defaultBit(o: { x?: number }, b: boolean) {
const { x = match (b) { when true: try succeed(7); default: 0; } } = o
return x
}import { Effect } from "effect"
function defaultBit(o: { x?: number }, b: boolean) {
return Effect.gen(function* () {
const { x = b === true ? yield* Effect.succeed(7) : 0 } = o
return x
})
}is expressions
subject is P evaluates the subject once and returns the pattern's Boolean result, using the same cache and finishing rules as match. In an ordinary block expression or an if head, its let and const names belong to the nearest enclosing lexical home, as the pinned explainer's binding rule states for a block: a block, a match arm or one of the other homes. They are in scope in both branches and after the if within that home, shadow outer names throughout it, and exist even when short-circuiting skips the expression or a preceding subpattern fails. Each cell stays in TDZ until its binding pattern executes. A failed overall test does not undo bindings already initialized; a skipped binding is still uninitialized. Source assignments keep ordinary const immutability. The pinned duplicate-initialization rules and their or replacement exception apply within one top-level pattern. Two top-level patterns that bind one name in the same lexical home are a redeclaration, as two let declarations are, and report what such a let reports: TS2451 beside another lexical declaration, TS2300 beside a parameter (VIP-7). Separate blocks, match arms and the other homes are separate homes. The diagnostic offers to rename the later binding and its uses, and vibelang --fix applies that rename.
An is in a loop head uses the pinned loop-head/per-iteration scopes. Each complete while or do…while condition evaluation has fresh cells; the initial do body sees its uninitialized head cells. A match arm has its own lexical scope, so an is inside it does not export names to another arm.
These bindings take part in TypeScript's definite-assignment and use-before-declaration checks, as a let assigned where the pattern runs would. A read on a path where the binding may not have run, because short-circuiting skipped it or the pattern failed before reaching it, follows TypeScript’s definite-assignment checks (TS2454), including its exemptions for any, unknown, void and types containing undefined; a read before the binding's position in its scope is TS2448. In for (init; test; update), a direct read in update of a binding from test follows the same type-dependent TS2454 checks: update starts in a fresh iteration environment before the test that initializes that cell. The previous body's initialization is not carried forward. Bindings introduced by update also start uninitialized; a read after that update's own pattern initializes its binding follows ordinary definite-assignment checking. A read those checks can't see, such as one inside a closure, throws ReferenceError while the cell is uninitialized.
// compiler-gap: pending VIP-7
function nameOf(value: { name: string } | undefined) {
if (value is { let name }) return name
return "missing"
}
function failedGuard() {
if (5 is let n and if (false)) return 0
return n // 5: the binding ran before the guard failed
}
effect function skippedBinding() {
// @ts-expect-error: TS2454, short-circuiting skipped the binding's initialization
if (true || (1 is const n)) return n
return 0
} // Die(ReferenceError)
function pick(a: unknown, b: unknown) {
if (a is { id: let id }) return id
// @ts-expect-error: TS2451, id is already declared in this block
if (b is { id: let id }) return id
return 0
}
function pickOk(a: unknown, b: unknown) {
if (a is { id: let first }) return first
if (b is { id: let second }) return second
return 0 // pickOk({}, { id: 1 }) is 1
}The object counterpart specializes its stated subject type. Native lexical-cell witnesses check initialization before a failed guard and TDZ after a skipped initialization:
import { Effect } from "effect"
function nameOf(value: { name: string } | undefined) {
const subject = value
if (subject !== undefined && "name" in subject) {
const name = subject.name
return name
}
return "missing"
}
function failedGuardCell() {
let n = 5
if (false) return 0
return n
}
function skippedBindingCell(): number {
// @ts-expect-error: the read precedes the skipped lexical initialization
if (true) return n
const n = 1
return 0
}
const isNamesExit = Effect.runSyncExit(Effect.sync(() => [nameOf({ name: "Ada" }), nameOf(undefined)])) // Success(["Ada", "missing"])
const failedGuardCellExit = Effect.runSyncExit(Effect.sync(failedGuardCell)) // Success(5)
const skippedBindingCellExit = Effect.runSyncExit(Effect.sync(skippedBindingCell)) // Die(ReferenceError)Typing
Patterns type as the TypeScript counterpart this chapter shows for them, under the project's compiler options; the table below illustrates tsc --strict (VIP-7, amended).
- Result. A
matchexpression has the union of its arm types; athrowarm contributesnever. Amatchwith nodefaultwhose subject passesEK4701(Exhaustiveness) adds nothing for the no-match path. For arm-type unionU,async matchhas typePromise<Awaited<U>>, with TypeScript's async-return resolution and Promise-like/thenable checks: a returned Promise is adopted, not nested.x is Phas typebooleanand narrowsxas a type predicate wherePnarrows. - Subject narrowing. Inside an arm the subject narrows as the arm's test narrows in TypeScript; a
defaultarm follows TypeScript control flow after every preceding ordered pattern and guard test has failed, including cached reads and source mutations. This is independent of exhaustiveness coverage: a predicate false branch may exclude a type even though the predicate covers noEK4701case; failure of a guard does not establish that its positive predicate failed. A guardand if (c)narrows further, asif (c)would. - Bindings. A
letbinding has the type TypeScript gives the value it is bound to in the counterpart.
| Pattern | Narrows s to | Binding types |
|---|---|---|
literal "a", 1, null, undefined, enum member | s === v | none |
object { _tag: "Sent", let tracking, id } | (typeof s === "object" || typeof s === "function") && s !== null && "_tag" in s && s._tag === "Sent" && "tracking" in s && "id" in s | tracking: the narrowed subject's property type |
array [let a, let b] | members that are iterable | the element type of the subject's iterable (tuple element types for a tuple) |
error / schema error class NotFound, NotFound(let e) | subject members with _tag: "NotFound", if any; otherwise NotFound | e: those subject members; otherwise the declared NotFound |
class C with an own matcher | only narrowing established by its matcher invocation; no implicit instanceof | elements of the declared extraction iterable, excluding false |
ordinary class C without an own matcher | s instanceof C | bare test has no bindings; any extractor is EK0704 (Class patterns) |
built-in Array(let n), typed-array extractor | Array.isArray(s), s instanceof C respectively | elements of the subject array or typed array (number or bigint) |
| other built-in constructor extractor | its specified recognition test | elements of its actual draft extraction list; Map and Set return [s], so bind the collection |
primitive class String(let t), Number, … | typeof s === "string" || s instanceof String, … | t: string, … |
fixed Option.Some(let v) | Option.isOption(s) && Option.isSome(s) | v: A for Option<A>; unknown for an unknown subject |
fixed Exit.Failure(let c), Result.Success(let v) | the module guards (Retained fixed patterns) | c: Cause.Cause<E>; v: A |
retained error Cause.TimeoutError(let t) | Cause.isTimeoutError(s) | t: Cause.TimeoutError, so t._tag is "TimeoutError" |
own matcher object M(let x) | only narrowing established by its matcher invocation | x: an element of its declared extraction iterable, excluding false |
call-only predicate p | its declared type predicate, as if (p(s)) | bare: none; extractor p(let x): an element of its declared return iterable, excluding false |
regex /(\d+)/(let m) | none | m: RegExpMatchArray (non-global); RegExpExecArray elements for a global regex |
relational < 0 | none | none |
not P | the false branch of the actual positive test: !(s instanceof C) only for an ordinary class using instanceof; an own matcher, generated error matcher or fixed guard complements its own invocation/test. A boolean-returning custom matcher establishes no narrowing | none |
P or Q | the union of each side's narrowing | a name bound in every alternative gets the union of its types |
P and Q | both narrowings in order | both sides' bindings |
The table shows tsc --strict types. Other options change a binding as they change its counterpart's value: with exactOptionalPropertyTypes, { let x } on { x?: number } binds number; strictNullChecks: false removes the strict null distinctions. A pattern reads an element or key only after proving it exists, so noUncheckedIndexedAccess adds no undefined to that generated read. Actual payload undefined remains: [let v] on (number | undefined)[] binds number | undefined. A pattern key is not dotted property access, so noPropertyAccessFromIndexSignature reports no TS4111 at it. Written reads such as s[0] or s.a in an arm or guard keep both options (VIP-7, amended).
// compiler-gap: pending VIP-7
function isText(s: string | number): s is string { return typeof s === "string" }
function classify(s: string | number) {
return match (s) { when isText: s.length; default: String(s); }
} // default s: number, although isText contributes no exhaustiveness coverage
const adopted: Promise<number> = async match (true) { default: Promise.resolve(7); }
class OnlyNumbers {
static [Symbol.customMatcher](s: unknown): boolean { return typeof s === "number" }
}
// For s: OnlyNumbers | number, an arm `when not OnlyNumbers: s * 2;`
// is a TypeScript error: this boolean matcher establishes no narrowing.import { Effect } from "effect"
function isText(s: string | number): s is string { return typeof s === "string" }
function classify(s: string | number) {
if (isText(s)) return s.length
return String(s)
}
const adopted: Promise<number> = (async () => Promise.resolve(7))()
const adoptedExit = await Effect.runPromiseExit(Effect.promise(() => adopted)) // Success(7)
// Stand-in for the runtime-provided Symbol.customMatcher key.
const customMatcher: unique symbol =
((Symbol as typeof Symbol & { readonly customMatcher?: symbol }).customMatcher ?? Symbol.for("Symbol.customMatcher")) as any
class OnlyNumbers {
static [customMatcher](s: unknown): boolean { return typeof s === "number" }
}
function negated(s: OnlyNumbers | number) {
if (!OnlyNumbers[customMatcher](s)) {
// @ts-expect-error no implicit instanceof exclusion for an own boolean matcher
s * 2
}
}
const defaultNarrowingExit = Effect.runSyncExit(Effect.sync(() => [classify("text"), classify(7)])) // Success([4, "7"])The object row describes narrowing; evaluation uses the cache/property algorithms above. An extracted property of an unknown subject is unknown; a declared field has its narrowed property type.
Generated error matchers deliberately trust _tag: a matching foreign object need not carry every field or getter promised by the local error type (VIP-13). TypeScript intuition: the table keeps the subject’s own tagged members when available, but for unknown the fallback declared class type can still promise a getter absent from a foreign object.
// compiler-gap: pending VIP-7
error NotFound { id: string }
error Timeout
function errorId(e: NotFound | Timeout) {
return match (e) { when NotFound(let n): n.id; when Timeout: ""; }
}import { Data } from "effect"
class NotFound extends Data.TaggedError("NotFound")<{ readonly id: string }> {}
class Timeout extends Data.TaggedError("Timeout") {}
function errorId(e: NotFound | Timeout) {
if (e._tag === "NotFound") { const n = e; return n.id }
return ""
}
errorId(new NotFound({ id: "42" })) // "42"In errorLabel (Retained fixed patterns) t is Cause.TimeoutError, and the function's type is "TimeoutError" | "invalid" | "other".
// compiler-gap: pending VIP-7
class Numeric {
static [Symbol.customMatcher](s: unknown, _options: { matchType: "extractor" }): false | [number] {
return typeof s === "number" ? [s] : false
}
}
function first(s: number[]) {
return match (s) { when Array(let n): String(n); default: "other"; }
}
function numeric(s: unknown) {
return match (s) { when Numeric(let n): String(n); default: "other"; }
}
// first([1]) is "1"; numeric(7) is "7", although 7 is not a Numeric instance.The following is a local typing witness for one Array(let n) arm and a tuple-returning custom matcher. The native loop preserves the element type, and its tagged result carries the fact that one element was obtained before the binding read; an array-length comparison alone does not carry it in TypeScript. Tuple extraction keeps its positional types. The number annotation checks the selected element before the nonthrowing String(n) call. A payload that includes undefined keeps that type after the presence test. This witness does not replace the general cached Iterator Record, failed-extraction, ordered-arm or FinishMatch algorithms.
const numericMatcher: unique symbol =
((Symbol as typeof Symbol & { readonly customMatcher?: symbol }).customMatcher ?? Symbol.for("Symbol.customMatcher")) as any
class Numeric {
static [numericMatcher](s: unknown, _options: { matchType: "extractor" }): false | [number] {
return typeof s === "number" ? [s] : false
}
}
function first(s: number[]) {
const extracted = s // Array adapter, after Array.isArray(s)
let selected: IteratorResult<number, undefined> = { done: true, value: undefined }
for (const value of extracted) {
if (!selected.done) return "other" // a second element: native for-of closes
selected = { done: false, value }
}
if (!selected.done) {
const n: number = selected.value
return String(n)
}
return "other"
}
function numeric(s: unknown) {
const extracted = Numeric[numericMatcher](s, { matchType: "extractor" })
if (extracted !== false) {
const [n] = extracted
return String(n) // s remains unknown; n is number
}
return "other"
}
first([1]) // "1"
numeric(7) // "7"A negated pattern narrows by negating its actual positive test, and a default arm is typed by control flow after every preceding test failed, independent of exhaustiveness.
Pattern bindings
An or pattern shares one lexical cell for a name bound with the same keyword in its alternatives. A later alternative may replace a cell initialized by a failed earlier alternative of that or; closures saved by that earlier alternative observe the replacement. Ordinary TDZ before first initialization, user-facing const immutability, and duplicate-initialization restrictions outside that authorized replacement remain. Do not reset unrelated bindings or give alternatives disconnected cells.
Lexical is bindings in while and do…while have the pinned explainer's loop-head/per-iteration scope, rather than an enclosing block's scope. The loop head shadows outer names and does not leak outside the loop. Each complete condition evaluation starts with fresh uninitialized cells, including after continue; condition reads see TDZ until the matching pattern initializes them. A successful condition and its following body share those cells, so closures see that body's later let writes. Short-circuiting past a binding leaves it uninitialized even if the condition is true. Nested source blocks/functions retain their own scopes.
The first do…while body runs with the initial uninitialized head cells. Its direct reads precede the binding, so they are TS2448, and with the check suppressed they throw ReferenceError; closures captured there keep those cells permanently in TDZ. Each subsequent condition gets new cells; its true result shares them with the following body. continue reaches a fresh condition set, and false/break exits without exporting names. The initial closures are never rebound to a later condition. An if head belongs to the home that contains the if statement; the within-one-or replacement above is unchanged.
Every other home follows the pinned explainer's rule that the usual binding scopes apply, with ECMAScript's environments:
- In
for (init; test; update), a binding frominitbelongs to the loop head, as a name declared by aletorconstdeclaration ininitwould, whetherinitis a declaration or an expression: CreatePerIterationEnvironment copies aletbinding into each iteration, and aconstbinding stays in the loop scope. A binding fromtestorupdateis a fresh, uninitialized cell in each environment CreatePerIterationEnvironment creates, shared by the update, test and body that run there and never copied forward, as for awhilecondition. - In
for…in,for…of,for await,for concurrentandfor try, a binding from the head expression, which runs once in the head's own environment, is copied into each iteration's binding environment; a skipped binding is copied uninitialized. A binding from a default inside the head declaration belongs to that iteration's fresh binding environment. A binding in afor concurrentlimit belongs to the nearest home that encloses the loop, because the limit runs before the head environment exists (VIP-7); it is one cell, not copied into items. - In a concise arrow body, a binding belongs to the arrow body's scope, where the block-body form would declare it.
- In a class field or static field initializer, a binding belongs to that initializer's own scope, because ECMAScript evaluates each initializer as a separate method-like body. A
staticblock is a block. - An
enummember initializer has no lexical home in ECMAScript or the pinned explainer, so a binding introduced there isEK0705, as for a formal parameter:enum E { A = (1 is let one) ? 1 : 0 }isEK0705. - The statement body of
if,else,for,for…in,for…of,for await,for concurrent,for try,while,do…whileor a labeled statement, when it is not a Block, cannot be the lexical home of anispattern binding: reportEK0705, matching TypeScript'sTS1156for aletdeclaration in that position. The quick fix andvibelang --fixwrap that statement body in braces. Anifhead belongs to the home that contains theifstatement; anelse ifhead therefore needs braces around theelsebody when it binds. A binding inside an explicitdoblock, match arm, callable body or loop head keeps its own home. (VIP-7).
A test binding is available to its following body, but not to the next update's direct reads. Creating a closure does not read the cell; invoking it before the test initializes that iteration's cell throws ReferenceError.
// compiler-gap: pending VIP-7
function updateRead(next: () => number, use: (n: number) => void) {
for (; next() is let v; use(v)) {} // TS2454: this iteration's v is unassigned
}
function updateClosure(next: () => number) {
for (; next() is let v; () => v) {} // no direct read; closure retains the fresh cell
}
function bodyRead(next: () => number, use: (n: number) => void) {
for (; next() is let v;) { use(v); break } // initialized by this iteration's test
}
function updateBinding(next: () => number, use: (n: number) => void) {
for (;; (next() is let v) && use(v)) {} // initialized before the update's read
}Each home holds one name once. Two top-level patterns that bind one name in the same home are a redeclaration, TypeScript's TS2451, as two let declarations in one block are; so is a binding that collides with another lexical declaration of that home, and a collision with a parameter keeps the native diagnostic for that collision. Inside one top-level pattern the pinned duplicate-initialization rule and its or exception stay. Separate blocks and match arms are separate homes, so braces or distinct names are the repair.
// compiler-gap: pending VIP-7
function pick(a: unknown, b: unknown) {
if (a is { id: let id }) return id
if (b is { id: let id }) return id // TS2451: Cannot redeclare block-scoped variable 'id'
return 0
}
function pickOk(a: unknown, b: unknown) {
if (a is { id: let first }) return first
if (b is { id: let second }) return second
return 0 // pickOk({}, { id: 1 }) is 1
}Each binding stays in TDZ until its pattern runs, under the checks in is expressions.
// compiler-gap: pending VIP-7
function drain(it: Iterator<number>) {
const out: (() => number)[] = []
for (; it.next() is { done: false, value: let v }; ) out.push(() => v)
return out.map(g => g()) // [1, 2, 3] for [1, 2, 3]: a fresh v each iteration
}
function countdown(src: { n: number }) {
const seen: (() => number)[] = []
for (src.n is let n; n > 0; n--) seen.push(() => n)
return seen.map(g => g()) // [3, 2, 1] for n = 3: copied each iteration, as a let is
}
function ids(xs: unknown[]) {
const out: unknown[] = []
for (const x of xs) { if (x is { id: let id }) out.push(id) }
return out // [1, 2] for [{ id: 1 }, { id: 2 }]
}import { Effect } from "effect"
function drain(it: Iterator<number>) {
const out: (() => number)[] = []
for (;;) {
const r = it.next()
if (!("done" in r && r.done === false && "value" in r)) break
const v = r.value
out.push(() => v)
}
return out.map(g => g())
}
function countdown(src: { n: number }) {
const seen: (() => number)[] = []
for (let n = src.n; n > 0; n--) seen.push(() => n)
return seen.map(g => g())
}
function ids(xs: unknown[]) {
const out: unknown[] = []
for (const x of xs) {
if ((typeof x === "object" || typeof x === "function") && x !== null && "id" in x) { let id = x.id; out.push(id) }
}
return out
}
const callable = Object.assign(() => 0, { id: 7 })
const callableObjectExit = Effect.runSyncExit(Effect.sync(() => ids([callable]))) // Success([7])
const loopHomesExit = Effect.runSyncExit(Effect.sync(() => [drain([1, 2, 3][Symbol.iterator]()), countdown({ n: 3 }), ids([{ id: 1 }, { id: 2 }])])) // Success([[1, 2, 3], [3, 2, 1], [1, 2]])// compiler-gap: pending VIP-7
effect function alternative() {
return match ([5]) { when ([let x and String]) or { length: let x }: x; default: 0; }
} // Success(1): the failed left alternative initialized x with 5, the right replaces it with 1
function readLoop() {
const x = 99; let n = 0; const readers: (() => number)[] = []
while (n < 2 && (++n is let x)) { readers.push(() => x); x += 10 }
return [x, ...readers.map(f => f())] // [99, 11, 12]
}
effect function skipped() {
// @ts-expect-error: TS2454, the successful condition skipped initialization
while (true || (1 is const x)) { return x }
} // Die(ReferenceError)Native lexical-cell witnesses for the per-iteration and initial-head rules:
import { Effect } from "effect"
function loopCells() {
const x = 99
const readers: (() => number)[] = []
for (const input of [1, 2]) { let x = input; readers.push(() => x); x += 10 }
return [x, ...readers.map(f => f())]
}
const loopCellsExit = Effect.runSyncExit(Effect.sync(loopCells)) // Success([99, 11, 12])
let initialReader: () => number = () => 0
initialHead: {
initialReader = (): number => x
break initialHead
const x = 0
}
const initialHeadExit = Effect.runSyncExit(Effect.sync(initialReader)) // Die(ReferenceError); later cells cannot initialize this closed-over bindingA later or alternative may replace a binding that a failed alternative initialized, keeping that cell's identity. is bindings in a while or do…while condition use a for-of-style loop scope with fresh cells each iteration.
Exhaustiveness
VibeLang adds these checks to TC39's proposal; EK4701 is the exhaustiveness check.
| Rule | Diagnostic |
|---|---|
a match with no default over a finite union that misses a case | EK4701 |
invalid match/is grammar or a pattern-specific early error without a more specific diagnostic | EK0707 |
a missing ; after a match arm, the last one included | EK0701 |
an is pattern binding in an unbraced statement body, or in formal-parameter or catch-parameter initialization with no explicit block/arm as its lexical home (Parameter initialization) | EK0705 |
a class pattern naming a data declaration or variant, a schema, or a directly constructed Context.Reference; a call-only callee whose return type fails the rule of Class patterns, or a callee with neither signature (services use EK3004; Class patterns) | EK0702 |
| an ordinary-class extractor without an own matcher | EK0704 |
| lint warning: an extractor whose argument count can never match its pattern's list (Class patterns) | EK0703 |
a return, break or continue that leaves an async match (Async jumps) | EK0706 |
EK4701 applies equally to match and async match, which have the same arms. The check only reports errors. It never changes what runs: a match that passes the check compiles to the same tests as one that doesn't need it.
One algorithm decides every subject (VIP-7, amended):
- Subjects. Start from TypeScript's type of the subject expression at that evaluation point, including its represented flow refinements. Resolve its effective base constraint when one exists; otherwise keep that type. Normalize the result into a union of members, expanding
booleantotrue | false, an enum to its members, adatatype to its variants,Option.Option<A>toNone | Some, andExit.Exit<A, E>andResult.Result<A, E>toSuccess | Failure.EK4701applies when every member is a literal type,true,false,null,undefined, an enum member, adatavariant, anerrororschema errorinstance, one of those sixOption,ExitandResultcases, a retained Effect error (Retained fixed patterns), or a tagged member. Any other member (string,number, an untagged object or class instance,unknown,any) turns the check off for thatmatch, which keeps TC39's run-timeTypeError. - Generic constraints and flow. A constrained type parameter uses its finite constraint as a conservative domain; an unconstrained parameter or a broad constraint stays open. A represented refinement such as
T & { _tag: "A" }can reduce that domain to"A". If TypeScript still exposes bareTafter an equality or property guard, its whole constraint remains: the check does not invent extra narrowing from the guard's text. Ordinary literals keep their literal types, without apparent-type boxing toNumber,StringorBoolean. This coverage projection neither casts the subject nor changes arm or default-flow types. - Tagged members. Only
_tagis a discriminant, Effect's convention, andEK4701counts the literal cases ofswitch (s._tag). Coverage is per normalized member and tag case, even when TypeScript does not narrow the whole subject because every member has the same_tagtype. A member is tagged when it is an object, interface or class instance and its_tagis what TypeScript calls a literal type: a string, number, bigint, boolean, enum,nullorundefinedliteral, aunique symbol, or a union of those. Each of those literals is a case of the member, and an optional_taghas the caseundefined. A_tagof any other type, such asstringor a template type, is not tagged, because TypeScript checks noswitchover it. - Coverage. Only an unguarded arm covers anything. A literal,
null,undefinedor enum-member pattern covers that member. A signed Number literal whose value is zero (+0,-0,-0.0,+0e0, and equivalent spellings) covers no zero case: itsSameValuetest distinguishes signs, while TypeScript's0type admits both. Even both signed arms together give no whole-case proof; use unsigned zero ordefault. Unsigned zero usesSameValueZeroand covers both signs. Valid signed nonzero literals retain singleton coverage; bigint zero has no distinct negative zero. The same rule applies to a checked zero-valued enum case. A bare binding (let xorconst x) covers every member.when NotFound:,when NotFound(let e):andwhen NotFound(const e):cover each member's"NotFound"tag case, including a plain object with that tag; other tag cases on that member remain;when NotFound():andwhen NotFound({ id: "x" }):cover nothing. A fixed pattern (Option.Some,Option.None,Exit.Success,Exit.Failure,Result.Success,Result.Failure) or a retained error covers its case when it is bare, or when its argument list has the required length and every argument pattern is a bareletorconstbinding. A bare pattern has no argument list, which is not an empty list:when Option.Some:andwhen Option.Some(let v):coverSome;when Option.Some():andwhen Option.Some(1):do not. An object pattern with_tag: "V"covers caseVof eachdatavariant or tagged member when every other property it tests is a bare key or aletorconstbinding that the member declares as required:"x" in sremoves a member from its false branch only wherexis required. Members that share a tag are covered one at a time, and one left uncovered stays uncovered. Theundefinedcase of an optional_tagis covered only by a bare binding, because{ _tag: undefined }tests"_tag" in sfirst and an absent tag fails that test.orcovers the union of its alternatives.and,not, regex, relational, array, built-in matcher and user-matcher patterns cover nothing, so those arms need adefault.
// compiler-gap: pending VIP-7
function signedZero(n: 0 | 1) {
return match (n) { when -0: "negative"; when 1: "one"; }
} // EK4701: positive zero is not covered
function allZero(n: 0 | -1) {
return match (n) { when 0: "zero"; when -1: "negative one"; }
} // exhaustiveimport { Effect } from "effect"
function signedZero(n: 0 | 1) {
if (Object.is(n, -0)) return "negative"
if (n === 1) return "one"
throw new TypeError()
}
function allZero(n: 0 | -1) {
if (n === 0) return "zero"
if (n === -1) return "negative one"
throw new TypeError()
}
const positiveZeroExit = Effect.runSyncExit(Effect.sync(() => signedZero(0))) // Die(TypeError)
const negativeZeroExit = Effect.runSyncExit(Effect.sync(() => signedZero(-0))) // Success("negative")
const unsignedZeroExit = Effect.runSyncExit(Effect.sync(() => [allZero(0), allZero(-0), allZero(-1)])) // Success(["zero", "zero", "negative one"])// compiler-gap: pending VIP-7
match (o) { when Option.Some(let v): v; when Option.None: 0; } // ok: Option<number>, covered
match (o) { when Option.Some: 1; when Option.None: 0; } // ok: a bare pattern covers its case
match (r) { when NotFound: 1; when undefined: 2; } // ok: NotFound | undefined
data Delivery { Waiting; Sent { tracking: string } }
declare const d: Delivery
match (d) { when { _tag: "Sent", let tracking }: 1; } // EK4701: Waiting not covered
match (d) { when { _tag: "Sent" } or { _tag: "Waiting" }: 1; } // ok
match (plain) { when { _tag: "Sent" }: 1; } // plain: { _tag: "Sent" } | { _tag: "Waiting" }: EK4701
match (s) { when { _tag: "A" }: 1; } // s: { _tag: "A"; x: number } | { _tag: "A"; y: number }: ok
match (s) { when { _tag: "A", let x }: x; } // EK4701: the y member is left
match (o) { when { _tag: "A" }: 1; when { _tag: "B" }: 2; } // o: { _tag?: "A" } | { _tag: "B" }: EK4701: the absent-tag case remains uncovered; only a bare binding covers it
match (u) { when { _tag: "A" }: 1; when { _tag: "C" }: 3; } // u: { _tag: "A" | "B" } | { _tag: "C" }: EK4701: "B" is left
match (w) { when { _tag: "A" }: 1; } // w: { _tag: "A" } | { _tag: string }: no check, as for a switch
match (k) { when { kind: "a" }: 1; } // k: { kind: "a" } | { kind: "b" }: no check
match (n) { when 1: "a"; } // n: number: no check
match (b) { when true and if (flag): 1; when false: 0; } // EK4701: a guarded arm covers nothingimport { Match } from "effect"
type Tagged = { _tag: "Sent"; tracking: string } | { _tag: "Waiting" }
const taggedLabel = Match.type<Tagged>().pipe(
Match.tag("Sent", (s) => s.tracking),
Match.tag("Waiting", () => "waiting"),
Match.exhaustive
)
taggedLabel({ _tag: "Sent", tracking: "1Z" }) // "1Z"tsc --strict checks the corresponding narrowing on the same shapes. Its whole-subject never check is not a coverage oracle for a uniform tag: the pattern still recognizes each member's case.
type Shared = { _tag: "A"; x: number } | { _tag: "A"; y: number }
function sharedTagSwitch(s: Shared) {
switch (s._tag) { case "A": return 1 }
// @ts-expect-error: TypeScript does not narrow a uniform tag; the pattern covers both members
const left: never = s
return left
}
function sharedTagPattern(s: Shared) {
if (s._tag === "A") return 1
throw new TypeError()
}
function shared(s: Shared) {
if (s._tag === "A" && "x" in s) return s.x
// @ts-expect-error: the y member is left
const left: never = s
return left
}
type Optional = { _tag?: "A" } | { _tag: "B" }
function optional(o: Optional) {
switch (o._tag) { case "A": return 1; case "B": return 2 }
// @ts-expect-error: { _tag?: "A" } is left, until a `case undefined`
const left: never = o
return left
}
type Union = { _tag: "A" | "B"; x: number } | { _tag: "C" }
function union(u: Union) {
switch (u._tag) { case "A": return 1; case "C": return 3 }
// @ts-expect-error: "B" is left
const left: never = u
return left
}
function unionDone(u: Union) {
switch (u._tag) { case "A": return 1; case "B": return 2; case "C": return 3 }
const left: never = u
return left
}
type Units = { _tag: 1 } | { _tag: true } | { _tag: null }
function units(n: Units) {
switch (n._tag) { case 1: return 1; case true: return 2; case null: return 3 }
const left: never = n
return left
}
type Wide = { _tag: "A" } | { _tag: string }
function wide(w: Wide) {
switch (w._tag) { case "A": return 1 }
// @ts-expect-error: string has no last case
const left: never = w
return left
}
type Payload = { _tag: "A"; x?: number } | { _tag: "B" }
function payload(p: Payload) {
if (p._tag === "A" && "x" in p) return 1
if (p._tag === "B") return 2
// @ts-expect-error: A without x is left
const left: never = p
return left
}A finite generic constraint is checked for every admitted instantiation; an open parameter retains the ordinary no-match TypeError:
// compiler-gap: pending VIP-7
type TaggedDomain = { readonly _tag: "A" } | { readonly _tag: "B" }
function tags<T extends TaggedDomain>(s: T) {
return match (s) { when { _tag: "A" }: 1; when { _tag: "B" }: 2; } // exhaustive
}
function missingTag<T extends TaggedDomain>(s: T) {
return match (s) { when { _tag: "A" }: 1; } // EK4701: B remains
}
function letters<T extends "A" | "B">(s: T) {
return match (s) { when "A": 1; } // EK4701: B remains
}
function open<T>(s: T) {
return match (s) { when "A": 1; } // open: no EK4701; a non-A value throws TypeError
}
function broad<T extends string>(s: T) {
return match (s) { when "A": 1; } // open: the constraint is string
}
function isA<T extends TaggedDomain>(s: T): s is T & { readonly _tag: "A" } {
return s._tag === "A"
}
function refined<T extends TaggedDomain>(s: T) {
if (isA(s)) return match (s) { when { _tag: "A" }: 1; } // exhaustive: represented intersection
return 0
}
function bareGuard<T extends TaggedDomain>(s: T) {
if (s._tag === "A") return match (s) { when { _tag: "A" }: 1; } // EK4701: reference still has type T
return 0
}
function ordinary(s: TaggedDomain) {
if (s._tag === "A") return match (s) { when { _tag: "A" }: 1; } // exhaustive: represented union refinement
return 0
}
match (0) { when 0: 1; } // exhaustive: 0 is not boxed to Numberimport { Effect } from "effect"
type TaggedDomain = { readonly _tag: "A" } | { readonly _tag: "B" }
function tagCoverage<T extends TaggedDomain>(s: T) {
switch (s._tag) { case "A": return 1; case "B": return 2 }
throw new TypeError()
}
function literalCoverage<T extends "A" | "B">(s: T) {
switch (s) { case "A": return 1; case "B": return 2 }
throw new TypeError()
}
function openCoverage<T>(s: T) {
if (s === "A") return 1
throw new TypeError()
}
const genericCoverageExit = Effect.runSyncExit(Effect.sync(() => [
tagCoverage({ _tag: "A" }), tagCoverage({ _tag: "B" }),
literalCoverage("A"), literalCoverage("B"), openCoverage("A")
])) // Success([1, 2, 1, 2, 1])
const openCoverageExit = Effect.runSyncExit(Effect.sync(() => openCoverage("other"))) // Die(TypeError)A generated error matcher tests the tag of the subject, including a foreign plain object with that tag:
// compiler-gap: pending VIP-7
error SameTag {}
declare const same: SameTag | { readonly _tag: "SameTag"; source: string }
match (same) { when SameTag: 1; } // exhaustive: both members have the recognized tagimport { Data, Effect } from "effect"
class SameTag extends Data.TaggedError("SameTag") {}
type SameTagSubject = SameTag | { readonly _tag: "SameTag"; source: string }
function sameTagPattern(s: SameTagSubject) {
if (s._tag === "SameTag") return 1
throw new TypeError()
}
const sameTagExit = Effect.runSyncExit(Effect.sync(() => [
sameTagPattern(new SameTag()),
sameTagPattern({ _tag: "SameTag", source: "foreign" })
])) // Success([1, 1])// compiler-gap: pending VIP-7
effect function bit(b: boolean) {
return match (b) { when true: 1; when false: 0; }
}
// Guarding the true arm requires another unguarded true arm or default.import { Effect } from "effect"
function bit(b: boolean) {
return Effect.gen(function* () {
const subject = b
if (subject === true) return 1
if (subject === false) return 0
throw new TypeError()
})
}AsyncMatchExpression is a primary expression; no line terminator may separate async and match. async match (s) { … } has the async-function behavior (async () => match (s) { … })(): subject evaluation, tests and the selected arm run inside the async function. await is allowed there; a thrown exception, including the no-match TypeError, rejects the resulting Promise. The explicit VIP-7 reconciliation gives the subject, tests and arms [~Yield, +Await, ~Return, ~Effect] contexts, even inside a plain function. Under VIP-7, a return in it, and a break or continue whose target is outside it, are syntax errors. Nested source callables retain their own grammar contexts.
EK0706 reports a jump out of an async match: a return that belongs to the async match, or a break or continue whose target is outside it. The rule covers the subject, patterns, guards and arm expressions, including a do expression in them. A return in a nested function belongs to that function, and a break or continue whose target is inside the async match follows the ordinary rules. Ownership and targets come from the source, not from the generated async function; a break or continue with no target anywhere keeps TypeScript's own error. No quick fix rewrites the jump, because no jump can cross the Promise boundary.
// compiler-gap: pending VIP-7
function outward(xs: number[]) {
for (const x of xs) {
const early = async match (x) { when 0: do { return 1 }; default: 0; } // EK0706: the return belongs to the async match
const stop = async match (x) { when 0: do { break }; default: 0; } // EK0706: the loop is outside the async match
}
}
function inward() {
return async match (0) {
when 0: (() => {
let value = 0
local: for (let i = 0; i < 3; i++) {
if (i === 0) continue local
value = i
break local
}
return value
})();
default: 0;
} // ok: the arrow owns its return, and the loop is inside it
}The rejected jumps have no counterpart. inward has the ordinary async counterpart:
import { Effect } from "effect"
function inward() {
return (async () => {
const subject = 0
if (subject === 0) {
return (() => {
let value = 0
local: for (let i = 0; i < 3; i++) {
if (i === 0) continue local
value = i
break local
}
return value
})()
}
return 0
})()
}
const inwardExit = await Effect.runPromiseExit(Effect.promise(inward)) // Success(1)asyncLabel shows the async-function behavior:
// compiler-gap: pending VIP-7
function asyncLabel(s: Promise<string>) {
return async match (await s) {
when "sent": await Promise.resolve("sent");
}
}
function asyncNested(s: Promise<string>) {
return async match (await s) {
when "sent": (() => { return 1 })();
default: 0;
}
}function asyncLabel(s: Promise<string>) {
return (async () => {
const subject = await s
if (subject === "sent") return await Promise.resolve("sent")
throw new TypeError()
})()
}
function asyncNested(s: Promise<string>) {
return (async () => {
const subject = await s
if (subject === "sent") return (() => { return 1 })()
return 0
})()
}// compiler-gap: pending VIP-7
function grammarAsync() {
return async match (await Promise.resolve(7)) { when 7: await Promise.resolve(9); default: 0; }
} // the plain outer function returns Promise<number>import { Effect } from "effect"
function grammarAsync() {
return (async () => {
const subject = await Promise.resolve(7)
if (subject === 7) return await Promise.resolve(9)
return 0
})()
}
const grammarAsyncExit = await Effect.runPromiseExit(Effect.promise(grammarAsync)) // Success(9)Inside effect code, a direct async match is a compile error under the Promise-bridge rule: like a Promise-returning call or new expression, it must run inside an explicit bridge's thunk. Its subject, tests and arms form a plain async boundary ([~Effect, +Await]): await is allowed there and effect try is not, as in a nested TypeScript async function. For example:
// compiler-gap: pending VIP-7
effect function asyncBit(b: boolean) {
return try promise(() => async match (b) {
when true: await Promise.resolve(1);
default: 0;
})
}import { Effect } from "effect"
function asyncBit(b: boolean) {
return Effect.gen(function* () {
return yield* Effect.promise(() => (async () => {
const subject = b
if (subject === true) return await Promise.resolve(1)
return 0
})())
})
}A signed zero pattern, +0 or -0, covers no case of TypeScript's literal type 0, because that type admits both zeros.
is bindings in parameter initialization
An is test in formal-parameter initialization is EK0705 if a let or const pattern binding would need the parameter environment as its lexical home. This includes direct defaults, defaults nested in parameter destructuring, and evaluated computed binding keys. The same holds for a catch clause's parameter, whose environment is distinct from the catch Block (VIP-7). The pinned explainer leaves that scope unfinished, so no parameter-home binding is invented or moved into the body.
A binding-free test remains allowed. A binding in an explicit source block within the initializer, including a do block or a match arm's scope, belongs to that block/arm and does not escape to other parameters or the body. Nested source callable bodies keep their own scopes and boundaries. Ordinary call arguments in a lexical block retain that block's binding rule; they are not formal parameters.
A repair preserves eager, left-to-right, once-only initialization, parameter/body environment separation, length, TDZ and closure identity. An explicit local block can return the needed result; a different explicit parameter is an option only when it preserves the intended API. There is no generic move-to-body quick fix. Execution and exception boundaries retain the shared default rules.
// compiler-gap: pending VIP-7
function bad(a = (1 is let captured)) { return a } // EK0705
function nested({ a = (1 is const captured) } = {}) {} // EK0705
function key({ [(1 is let captured) ? "x" : "y"]: value }: { x?: number; y?: number } = {}) {} // EK0705
function caught() { try { work() } catch ({ code = (1 is let c) }: any) {} } // EK0705
function testOnly(a = (1 is 1)) { return a } // allowed: no new binding
function good(a = do { const ok = 1 is let captured; [ok, captured] }) { return a }
function arm(a = match (1) { when let x: (1 is let captured) ? captured : 0; }) { return a }
function body(a = () => (1 is let captured)) { return a } // nested source body
function reader(seed: number, a = do { seed is let captured; () => ++captured }) { return a }
function caller() { consume(1 is let captured); return captured } // caller's lexical blockFor the simple non-jumping block-local default and ordinary call-argument witnesses, the native counterparts preserve the local cell and call phase:
import { Effect } from "effect"
function good(a = (() => { let captured = 1; const ok = true; return [ok, captured] })()) {
return a
}
function testOnly(a = (1 === 1)) { return a }
function reader(seed: number, a = (() => { let captured = seed; return () => ++captured })()) { return a }
function caller() {
let captured = 1
consume(true)
return captured
}
function consume(_value: boolean) {}
const defaultEvents: string[] = []
function eager(
a = (() => { defaultEvents.push("default"); let captured = 1; return captured })(),
b = (defaultEvents.push("later"), 2)
) {
defaultEvents.push("body")
return [a, b]
}
const parameterScopeExit = Effect.runSyncExit(Effect.sync(() => [good(), testOnly(), caller(), eager()])) // Success([[true, 1], true, 1, [1, 2]])
defaultEvents // ["default", "later", "body"]
const readerScopeExit = Effect.runSyncExit(Effect.sync(() => {
const first = reader(1), second = reader(100)
return [first(), second(), first()]
})) // Success([2, 101, 3])Regular-expression patterns
A RegularExpressionPattern admits a bare RegularExpressionLiteral or its extractor suffix (MatchList), including the nullable list (). It evaluates the literal once at its pattern position and matches as the same named RegExp value does. The runtime emulates the draft's RegExp.prototype matcher without patching the prototype: a bare pattern calls test; a non-global extractor returns [regexp[Symbol.match](subject)], failing on null. A global extractor returns Array.from(regexp[Symbol.matchAll](subject)) directly, failing when empty. Thus a non-global regex extracts one match array, not separate capture arguments; a global regex extracts one element per match. Calls preserve the regex receiver and the draft's observable property reads and lastIndex effects.
// compiler-gap: pending VIP-7
function digits(s: string) {
return match (s) {
when /(\d+)-(\d+)/(let m): m[1] + m[2];
default: "none";
}
}import { Effect } from "effect"
function digits(s: string) {
const regexp = /(\d+)-(\d+)/
const result = regexp[Symbol.match](s)
if (result !== null) {
const extracted: [RegExpMatchArray] = [result]
const [m] = extracted
return m[1] + m[2] // reads the source writes keep noUncheckedIndexedAccess
}
return "none"
}
digits("12-34") // "1234"
const regexPatternExit = Effect.runSyncExit(Effect.sync(() => [digits("12-34"), digits("none")])) // Success(["1234", "none"])Class patterns
A class arm whose class has its own Symbol.customMatcher (an own property, Object.hasOwn(C, Symbol.customMatcher)) reads and calls that matcher when the pattern runs, as the draft says. A static matcher inherited from a superclass is not an own matcher: for class B extends A {}, when B: tests s instanceof B. Inherited lookup would make class Sub extends NotFound {} match every NotFound through its superclass’s tag matcher. VIP-7's retained fixed patterns below are recognized before this test. Any other class uses instanceof; the runtime never installs the draft's Function.prototype matcher.
- An
errorclass (Failures), including aschema error, gets a matcher that tests_tag. It returns[subject]on a match andfalseotherwise;when NotFound(let e):binds that subject.when NotFound():never matches: the matcher returns one element, but the extractor has zero argument patterns. Writewhen NotFound:for a bare test. Thuswhen NotFound:matches across copies of the same package. The TypeScript counterpart is in Failures. datavariants get no custom matchers. Match them with object patterns on_tag:when { _tag: "Sent", let tracking }: …(Data). Onlyerrorandschema errordeclarations, and the retained Effect checks, have their own matcher.- A class pattern whose expression resolves to a
datadeclaration or variant constructor, aschemadeclaration or a directly constructedContext.ReferenceisEK0702: under the draft such a function would be called with the subject and match almost anything. The quick fix for a variant writeswhen { _tag: "Sent" }:; for a schema, testSchema.is(Address)(x)in a guard. Everyservicedeclaration, with or withoutdefaultValue, reportsEK3004instead (Services). - No run-time patching of Effect. The forty fixed patterns below are compiler intrinsics until Effect ships matchers. The names are pattern syntax, not values exported by Effect 4.1.1. Recognition follows the resolved module binding, including aliases:
import * as O from "effect/Option"permitswhen O.Some(let v):. A localOptioninstead uses TC39's matcher lookup; spelling alone never selects an intrinsic. - Thirty-four of the forty are Effect’s tagged errors, each tested with its module’s own guard. The alias and shadowing rule above applies. Neither
instanceof, a universal_tagtest nor the subject’s static type replaces the guard. - Built-in constructor patterns use
Array.isArray(subject)forArray, andsubject instanceof Cfor every other built-in, including native errors,AggregateError,Date,RegExp, collections,Promise, typed arrays andArrayBuffer. A cross-realm array matches; other cross-realm built-ins may fail. Native-error matching follows the current prototype chain, not an inaccessible original native-error kind.String,Number,Boolean,BigIntandSymboltest the primitive or its boxed object,typeof s === "string" || s instanceof Stringand so on, and extract[s]for a primitive and[s.valueOf()]for a boxed object. Extraction otherwise follows the draft; no methods are defined on globals. Ordinary classes without an explicit matcher, including classes imported from untouched.tsor.js, instead testsubject instanceof C, then extract as the draft specifies. No helper patchesFunction.prototypeor records construction identity. This deliberately differs from the snapshot's[[ConstructedBy]]test: changing a genuine instance's prototype can make it stop matching, while forging a prototype can make an object match. A future switch to engine matchers requires a VIP. Where an engine lacksSymbol.customMatcher, the runtime defines the symbol (Runtime guarantees).
Every .vibe and .vibex source file sees the runtime's declaration of SymbolConstructor.customMatcher, including when vibe.effectModules or vibe.effectGlobals is disabled. Thus an ordinary class or object may declare its own [Symbol.customMatcher] method without a source import or reference directive. The ambient SymbolConstructor augmentation is program-wide, so .ts files in the same TypeScript program see it too; a standalone .ts program without the provider does not. Symbol.customMatcher has the same unique symbol type as the runtime's customMatcher export. This supplies the type required by VIP-7; matching still uses the own-matcher rules above. A type reference does not execute runtime initialization: emitted VibeLang modules using the key request the runtime initialization dependency first. TypeScript modules, including VIP-40 bodies, keep ordinary dependency order and arrange initialization before using the key, normally with import "vibelang-runtime" first. The ambient type alone does not establish that the property exists at run time. Emitted declarations that name Symbol.customMatcher carry the runtime type reference under Declaration output.
For an ordinary class without an own matcher, the draft’s successful test returns true, not [s]: using it as an extractor throws TypeError, rather than binding an instance. Built-in adapters retain their own extraction rules: Array and typed arrays return the subject as the list; Map and Set return [subject].
After own matchers, fixed patterns, built-ins and declaration diagnostics, dispatch uses the callee’s static signatures: a construct signature selects instanceof, including ES5 constructors declared as constructable; call signatures only select C(subject, { matchType }), preserving a member receiver. A bare call-only pattern requires a return type assignable to boolean; an extractor requires Iterable<unknown> | false. Other return types, and callees with neither signature (any, unknown, Function, non-callable objects), are EK0702; quick fix: test the call in a guard. Type predicates narrow as ordinary TypeScript calls do. The global Function still reports EK0107; use a typeof guard.
// compiler-gap: pending VIP-7
function isText(s: unknown): s is string { return typeof s === "string" }
function callableLabel(s: unknown) {
return match (s) { when isText: s.toUpperCase(); when Array.isArray: s.length; default: 0; }
}function isText(s: unknown): s is string { return typeof s === "string" }
function callableLabel(s: unknown) {
// Runtime calls also receive the protocol hint; extra JS arguments do not affect these predicates.
if (isText(s)) return s.toUpperCase()
if (Array.isArray(s)) return s.length
return 0
}
callableLabel("sent") // "SENT"
callableLabel([1, 2]) // 2An ordinary-class extractor without an own matcher is EK0704, with any argument count, including (). An inherited matcher does not count. Quick fixes: when C:; or, when the arguments bind the constructor’s parameter properties, when C and { p1: let a, p2: let b }:. A bypass through any retains the draft’s run-time TypeError on recognition.
// compiler-gap: pending VIP-7
class Point { constructor(readonly x: number, readonly y: number) {} }
function sum(p: unknown) {
return match (p) { when Point and { x: let x, y: let y }: x + y; default: 0; }
}
// when Point(let x, let y): is EK0704; when Point: is a bare instanceof test.class Point { constructor(readonly x: number, readonly y: number) {} }
function sum(p: unknown) {
if (p instanceof Point && "x" in p && "y" in p) {
const x = p.x, y = p.y
return x + y
}
return 0
}
sum(new Point(2, 3)) // 5// compiler-gap: pending VIP-7
class A {
static [Symbol.customMatcher](s: unknown, _options: { matchType: "boolean" }) {
return s === "inherited"
}
}
class B extends A {}
function inherited(s: unknown) {
return match (s) { when B: "yes"; default: "no"; } // B has no own matcher: s instanceof B
}
function builtin(s: unknown) {
return match (s) { when Array: "array"; when TypeError: "type"; when String(let t): t; default: "other"; }
}const customMatcher: unique symbol =
((Symbol as typeof Symbol & { readonly customMatcher?: symbol }).customMatcher ?? Symbol.for("Symbol.customMatcher")) as any
class A {
static [customMatcher](s: unknown, _options: { matchType: "boolean" }) {
return s === "inherited"
}
}
class B extends A {}
// Stand-in for the runtime-provided Symbol.customMatcher key.
function inherited(s: unknown) {
// B has no own matcher (Object.hasOwn(B, customMatcher) is false), so the test is instanceof
return s instanceof B ? "yes" : "no"
}
function builtin(s: unknown) {
if (Array.isArray(s)) return "array"
if (s instanceof TypeError) return "type"
if (typeof s === "string" || s instanceof String) return typeof s === "string" ? s : s.valueOf()
return "other"
}
inherited("inherited") // "no": A's matcher is inherited, not own
inherited(new B()) // "yes"
const changed = new TypeError()
Object.setPrototypeOf(changed, Error.prototype)
builtin(changed) // "other"
builtin(Object.create(TypeError.prototype)) // "type"Zero-argument extractors: Option.None() matches ([]); NotFound() and the thirty-four retained error constructors with () never match ([s]). When a class or fixed pattern's extracted list has a statically known length (an error or schema error class and the thirty-four retained errors: 1; Option.Some, Exit.*, Result.*: 1; Option.None: 0) and its known length lies outside the admitted interval of an extractor without a rest pattern: required positions through total positions, counting every elision slot and counting optional elements only in the maximum, VibeLang reports the VIP-38 lint warning EK0703, with the quick fixes when X: and, for a singleton list, when X(let e):. The interval controls only this warning; actual list patterns still decide matching. For example, Option.None(1?) admits its empty extracted list and is not warned.
// compiler-gap: pending VIP-7
match (e) { when NotFound(): 1; default: 0; } // warning EK0703: never matches; write NotFound or NotFound(let e)
match (o) { when Option.None(): 1; default: 0; } // ok: its list is []// compiler-gap: pending VIP-7
class Foreign {}
effect function classify(x: unknown) {
return match (x) { when Foreign: "yes"; default: "no"; }
}
const real = new Foreign()
Object.setPrototypeOf(real, null)
const fake = Object.create(Foreign.prototype)
// classify(real) succeeds with "no"; classify(fake) succeeds with "yes".import { Effect } from "effect"
class Foreign {}
function classify(x: unknown) {
return Effect.gen(function* () {
return x instanceof Foreign ? "yes" : "no"
})
}
const real = new Foreign()
Object.setPrototypeOf(real, null)
const fake = Object.create(Foreign.prototype)
Effect.runSync(classify(real)) // "no"
Effect.runSync(classify(fake)) // "yes"Retained fixed patterns
VIP-7 keeps today's fixed checks until Effect ships matchers: the six Option, Exit and Result patterns and Effect 4.1.1's thirty-four tagged errors. Each is recognized by its resolved module binding, aliases included, shadowing excluded. s is the subject, evaluated once. A bare pattern only runs the test. An extractor matches the listed array against its argument patterns by the snapshot's list rules: without rest, the extracted length must fit the required-to-total position interval and every present sub-pattern must match. Zero arguments match only []; absent optionals consume zero positions, so Option.None(1?) also matches. An arity mismatch is a failed match (EK0703 warns).
| Pattern | Ordered test on s | List |
|---|---|---|
Option.Some | Option.isOption(s) && Option.isSome(s) | [s.value] |
Option.None | Option.isOption(s) && Option.isNone(s) | [] |
Exit.Success | Exit.isExit(s) && Exit.isSuccess(s) | [s.value] |
Exit.Failure | Exit.isExit(s) && Exit.isFailure(s) | [s.cause] |
Result.Success | Result.isResult(s) && Result.isSuccess(s) | [s.success] |
Result.Failure | Result.isResult(s) && Result.isFailure(s) | [s.failure] |
Cause.NoSuchElementError | Cause.isNoSuchElementError(s) | [s] |
Cause.TimeoutError | Cause.isTimeoutError(s) | [s] |
Cause.IllegalArgumentError | Cause.isIllegalArgumentError(s) | [s] |
Cause.ExceededCapacityError | Cause.isExceededCapacityError(s) | [s] |
Cause.AsyncFiberError | Cause.isAsyncFiberError(s) | [s] |
Cause.UnknownError | Cause.isUnknownError(s) | [s] |
Schema.SchemaError | Schema.isSchemaError(s) | [s] |
Cron.CronParseError | Cron.isCronParseError(s) | [s] |
PlatformError.PlatformError | PlatformError.isPlatformError(s) | [s] |
SemVer.SemVerError | SemVer.isSemVerError(s) | [s] |
Terminal.QuitError | Terminal.isQuitError(s) | [s] |
TextDiff.TextDiffError | TextDiff.isTextDiffError(s) | [s] |
AiError.AiError (effect/ai/AiError) | AiError.isAiError(s) | [s] |
McpClientError.McpClientError (effect/ai/McpClientError) | McpClientError.isMcpClientError(s) | [s] |
CryptoError.CryptoError (effect/crypto/CryptoError) | CryptoError.isCryptoError(s) | [s] |
Csv.CsvError (effect/encoding/Csv) | Csv.isCsvError(s) | [s] |
EncodingError.EncodingError (effect/encoding/EncodingError) | EncodingError.isEncodingError(s) | [s] |
JsonArray.JsonArrayError (effect/encoding/JsonArray) | JsonArray.isJsonArrayError(s) | [s] |
Ndjson.NdjsonError (effect/encoding/Ndjson) | Ndjson.isNdjsonError(s) | [s] |
Tar.TarError (effect/encoding/Tar) | Tar.isTarError(s) | [s] |
Xml.XmlError (effect/encoding/Xml) | Xml.isXmlError(s) | [s] |
Zip.ZipError (effect/encoding/Zip) | Zip.isZipError(s) | [s] |
GraphQLClientError.GraphQLClientError (effect/graphql/GraphQLClientError) | GraphQLClientError.isGraphQLClientError(s) | [s] |
Cookies.CookiesError (effect/http/Cookies) | Cookies.isCookiesError(s) | [s] |
Headers.HeaderParseError (effect/http/Headers) | Headers.isHeaderParseError(s) | [s] |
HttpBody.HttpBodyError (effect/http/HttpBody) | HttpBody.isHttpBodyError(s) | [s] |
HttpClientError.HttpClientError (effect/http/HttpClientError) | HttpClientError.isHttpClientError(s) | [s] |
HttpServerError.HttpServerError (effect/http/HttpServerError) | HttpServerError.isHttpServerError(s) | [s] |
Multipart.MultipartError (effect/http/Multipart) | Multipart.isMultipartError(s) | [s] |
JoseError.JoseError (effect/jose/JoseError) | JoseError.isJoseError(s) | [s] |
DatagramSocket.DatagramSocketError (effect/socket/DatagramSocket) | DatagramSocket.isDatagramSocketError(s) | [s] |
Socket.SocketError (effect/socket/Socket) | Socket.isSocketError(s) | [s] |
SqlError.SqlError (effect/sql/SqlError) | SqlError.isSqlError(s) | [s] |
WorkerError.WorkerError (effect/workers/WorkerError) | WorkerError.isWorkerError(s) | [s] |
The tagged-error guards recognize Effect's brands across installed copies and reject an unbranded object with only the same _tag. Every other constructor, including Config.ConfigError (no guard), PlatformError.BadArgument and PlatformError.SystemError, follows Class patterns: its own Symbol.customMatcher if present, otherwise instanceof. cli/CliError is a union with no constructor; its members and nested reason classes without companion guards use ordinary class matching. A VIP-32 Effect version bump updates this inventory.
// compiler-gap: pending VIP-7
import * as O from "effect/Option"
effect function unwrap(s: unknown) {
return match (s) {
when O.Some(let v): v;
when O.None: "none";
when Exit.Success(let v): v;
when Exit.Failure(let c): c;
when Result.Success(let v): v;
when Result.Failure(let e): e;
default: "other";
}
}
// A local Option is matched as an ordinary class instead (Class patterns).import { Effect, Exit, Option as O, Result } from "effect"
function unwrap(s: unknown) {
return Effect.gen(function* () {
const subject = s
if (O.isOption(subject) && O.isSome(subject)) return subject.value
if (O.isOption(subject) && O.isNone(subject)) return "none"
if (Exit.isExit(subject) && Exit.isSuccess(subject)) return subject.value
if (Exit.isExit(subject) && Exit.isFailure(subject)) return subject.cause
if (Result.isResult(subject) && Result.isSuccess(subject)) return subject.success
if (Result.isResult(subject) && Result.isFailure(subject)) return subject.failure
return "other"
})
}// compiler-gap: pending VIP-7
function errorLabel(e: unknown) {
return match (e) {
when Cause.TimeoutError(let t): t._tag;
when Schema.SchemaError: "invalid";
default: "other";
}
}import { Cause, Schema } from "effect"
function errorLabel(e: unknown) {
const subject = e
if (Cause.isTimeoutError(subject)) {
const t = subject
return t._tag
}
if (Schema.isSchemaError(subject)) return "invalid"
return "other"
}// compiler-gap: pending VIP-7
errorLabel(new Cause.TimeoutError()) // "TimeoutError"
errorLabel({ _tag: "TimeoutError" }) // "other": no Effect brandimport { Cause } from "effect"
const taggedErrorLabel = (e: unknown) =>
Cause.isTimeoutError(e) ? e._tag : "other"
taggedErrorLabel(new Cause.TimeoutError()) // "TimeoutError"
taggedErrorLabel({ _tag: "TimeoutError" }) // "other"// compiler-gap: pending VIP-7
import * as CryptoError from "effect/crypto/CryptoError"
function cryptoLabel(e: unknown) {
return match (e) { when CryptoError.CryptoError(let c): c._tag; default: "other"; }
}import * as CryptoError from "effect/crypto/CryptoError"
function cryptoLabel(e: unknown) {
if (CryptoError.isCryptoError(e)) {
const c = e
return c._tag
}
return "other"
}