Skip to content
LogoLogo

VIP-27: declaration output and @throws

Status: accepted, 2026-10-29, under Will's standing approval of recommendations ("assume I accept now. I will interrupt you if I need to").

Decision

// load.vibe
export effect function load(id: string) { … }
export function add(a: number, b: number) { return a + b }
 
// load.d.vibe.ts, written next to load.vibe
export declare function load(id: string): Effect.Effect<User, NotFound, Users>
/** @throws {never} */
export declare function add(a: number, b: number): number
  • Content. Declarations are exactly what idiomatic Effect TypeScript would declare, per Will's earlier ruling (D501).

  • Amended 2026-11-03: API identity (Will). Declarations are API-identical to tsc's for the Effect TypeScript twin: the same exported names, each export's type identical to tsc's in both directions (a type-level check), and the same doc comment on each export. Formatting, quote style, import extension spelling, and import and member order where tsc doesn't care don't count ("Byte identical seems a bit extreme shouldn't it have some level of discretion?"). Chapter 1 states it once; codemods and migration point there.

  • Location. x.d.vibe.ts is written next to each x.vibe, which TypeScript's allowArbitraryExtensions resolves, so plain tsc and TypeDoc understand .vibe imports. A package build also writes ordinary .d.ts files to its output directory.

  • @throws. A non-effectful exported function carries @throws {never}, because a .vibe function can't throw (VIP-4) and a TypeScript function with no @throws throws unknown. The effect function load carries none. A re-exported foreign callable keeps its actual contract.

  • The @throws annotations VibeLang ships for JavaScript's globals (VIP-4) use the same spelling. Construct the twin with the explicit @throws {never} contract on each non-effectful signature for which VibeLang emits it. Preserve the source documentation and add that tag to the twin's JSDoc before running tsc; do not add it to the .vibe source, where it is EK0406. This makes the same-comment comparison include the required cross-module contract: an unannotated TypeScript twin would instead advertise unknown and would not represent the same API. Effectful signatures and re-exported foreign callables retain their own declaration rules.

For the add example, the Effect TypeScript twin used for declaration comparison is:

/** @throws {never} */
export function add(a: number, b: number) { return a + b }

Stock tsc preserves the tag in the declaration, exactly as shown above. Without the tag, tsc emits the same function type with no JSDoc; that is a foreign callable with thrown type unknown under VIP-4.

  • Amended 2026-10-30: the generated matcher. An error or schema error keeps its static [Symbol.customMatcher] member. Every declaration file whose emitted text names Symbol.customMatcher, including an ordinary exported class or object with an own matcher, begins with /// <reference types="vibelang-runtime" />, which declares the Symbol.customMatcher type, so plain tsc checks it and matching across packages works at run time. Decided by the agent on 2026-10-30 under Will's rule "when I state a principle, the agent derives the concrete rule and acts; I can override later" (decided detail 67c).

  • Clarified 2026-11-03: matcher type visibility. VIP-7 admits user-written own matchers as well as generated ones. Every .vibe and .vibex source file sees the runtime's SymbolConstructor.customMatcher declaration independently of the Effect prelude switches. The type-reference rule above applies whenever emitted declaration text names Symbol.customMatcher, including an ordinary exported class or object. TypeScript's standard libraries do not declare this proposal symbol; the same runtime type declaration is required for the TypeScript twin.

Corrected 2026-10-29: load is declared with export declare function, because a declaration keeps its source form (VIP-1: effect goes exactly where TypeScript allows async) and idiomatic TypeScript declares a function declaration that way. The type is Effect.Effect<…>, as Effect TypeScript writes it.

Amendment: Bun project scripts

bun check runs the project's check script; a script that invokes a TypeScript checker uses that checker's sidecar resolution. Bun's script runner executes the configured command.

  • Amended 2026-11-01: documented schemas. A JSDoc comment on a schema declaration is emitted on its declare const only; its synthesized typeof Name.Type alias has no comment. This includes @internal, @public and @deprecated, as on the ordinary Effect TypeScript pair. Native removeComments and stripInternal behavior is unchanged. Without a written alias, synthesize the same bare alias.

Amendment: an opt-in sidecar directory (2026-11-01)

The Location bullet stays the default: x.d.vibe.ts beside x.vibe, with no configuration. vibe.sidecarDir moves every sidecar of the project into one directory, conventionally .vibe/declarations, at the source's path relative to TypeScript's declaration source root: explicit rootDir, the config directory for a composite project, or the common source directory otherwise. With rootDir: ".", src/x.vibe gets .vibe/declarations/src/x.d.vibe.ts. The project's tsconfig adds the directory to rootDirs beside ., so stock tsc, TypeDoc and editors still resolve import "./x.vibe" under allowArbitraryExtensions:

{
  "compilerOptions": { "allowArbitraryExtensions": true, "rootDir": ".", "rootDirs": [".", ".vibe/declarations"] },
  "vibe": { "sidecarDir": ".vibe/declarations" }
}

An imported source outside the compiling config's directory follows that compiling project's root and sidecarDir; its nearest config does not change ownership. An explicit rootDir excluding it reports TS6059. Through a project reference, only the owning project emits that source's sidecar. With no sidecarDir, the sidecar stays beside its source. This follows TypeScript's declaration-layout rule; the config-relative example above explicitly fixes the root.

The hidden directory keeps sidecars out of code that enumerates a source folder's *.ts files, such as a test that imports every *.ts but *.d.ts. Switching the setting on or off removes each module's sidecar from the other place. The migrate flow's setup turns it on where tracked code enumerates *.ts files in a folder that will hold .vibe files. Decided detail 268 (spec agent, 2026-11-01, common sense and precedent: TypeScript's declarationDir and rootDirs); it reverses nothing.

Compatibility references preserve the source module selected by the original program, including explicit .vibe imports with a same-basename native .ts sibling. Written imports, re-exports and inferred import types retain that identity before relocation. Ordinary .d.ts spelling is separate, under the 2026-11-03 amendment below; it cannot change which module a compatibility declaration represents. This follows the accepted Content contract and chapter 7’s source-import resolution.

Relative module references are written from the sidecar's final location. A reference to a VibeLang module targets that module's selected compatibility declaration; a reference to a native TypeScript module targets its native location. A consuming project does not inherit the producer's rootDirs, so producer-only virtual roots are not a substitute for correct relative specifiers. Public declarations still follow native declarationDir and outDir independently.

A workspace package's non-relative export, paths alias or package #imports entry resolving to a relocated sidecar uses its .vibe source when the owning config proves the mapping and TypeScript's final resolved path is outside node_modules. Config discovery follows VIP-30; inheritance and ${configDir} retain their normal meanings. Apply normal realpath and preserveSymlinks policy first: a retained logical path inside node_modules keeps its declarations, even when its physical target is a workspace. Published packages also keep their declarations. This dated clarification follows decided detail 285 (2026-11-01); it does not merge source and declaration identities that native symlink policy keeps separate. Explicit declaration inputs retain details 281 and 283's overwrite protection.

Migration setup recognizes TypeScript ast-grep rules and quoted or unquoted script globs as well as source walks and test includes. It records the reason, preserves an effective existing directory, and adds the matching virtual root. When setup switches the layout it removes superseded adjacent sidecars immediately, preserves orphan declarations and selected outputs, and makes replay idempotent. A hidden directory does not guarantee exclusion by every recursive walker; implementation/test identities still have to be preserved, or the affected source stays TypeScript. These clarify the directory amendment under decided details 268 and 271 (2026-11-01); they add no output marker or backup.

Prior art

Svelte's and Vue's TypeScript tooling; CSS modules' .d.css.ts; TypeScript's allowArbitraryExtensions.

  • Amended 2026-11-01: a sidecar is an emit output. Discovery through include or default globs selects the source and omits its compatibility declaration, as native .ts/.d.ts discovery does. A declaration explicitly selected in files remains an input: an attempted compatibility write over it reports TS5055, exits 1, and leaves its bytes unchanged. This includes .d.vibex.ts and mapped sidecar locations. VIP-27's independent compatibility write still runs under noEmit: true and declaration: false; those options retain their ordinary JavaScript/public-declaration output controls. The overwrite check follows that required write.

  • Amended 2026-11-01: sidecars reached by non-relative names. After native resolution and realpath/preserveSymlinks handling, a non-relative export, paths or #imports resolution to a known .d.vibe.ts/.d.vibex.ts sidecar uses its available source only when the final path is outside node_modules. With relocated sidecars, follow the recorded source map, never a guessed basename. A retained logical node_modules path under preserveSymlinks keeps declarations. This Vibe-aware source preference changes which file is read, not native discovery or explicit-input overwrite protection; an explicitly listed declaration remains an input. Ordinary TypeScript source/declaration resolution is unchanged.

  • Amended 2026-11-03: declaration import spelling follows TypeScript. Ordinary package declarations use the native twin’s relative specifiers: ./user.vibe is declared as ./user.ts and ./App.vibex as ./App.tsx; written ./user.js, ./App.jsx, ./user and native ./plain.ts keep their spelling. Synthesized import types use TypeScript’s spelling for the twin under the same imports and tsconfig. The twin does not enable extension flags; written source extensions follow the source checks. Compatibility sidecars instead keep VibeLang source references such as ./user.vibe and ./App.vibex, relocated to the selected compatibility declaration when necessary, to preserve source identity. Neither allowImportingTsExtensions nor rewriteRelativeImportExtensions rewrites declaration specifiers; JavaScript output rewriting is separate. The retired declarationImportStyle paths are ignored with nonfatal deprecation warning EK3011 for every value (VIP-30). This corrects the earlier substitution of VibeLang source extensions into ordinary package declarations, under Will’s standing TypeScript rule: a package ships .d.ts files, not compatibility sidecars.

An empty vibe.sidecarDir string names the directory of its declaring config, as an empty TypeScript path does. A present child value overrides an inherited path; an absent child value inherits. Non-string values report EK3007. This is the path-inheritance contract of the directory setting.

Directory paths may overlap the source tree; emit still mirrors paths relative to TypeScript's declaration source root, as defined in the opt-in-directory amendment, and stale cleanup preserves every module's selected output. Stock TypeScript keeps its native rootDirs longest-prefix resolution. A separate sidecar directory avoids virtual-path collisions with sources inside that directory.

Amendment: cyclic inferred declaration types (2026-11-01)

Declaration serialization retains TypeScript 5.9 and 7.0 behavior at the anonymous-type cycle sites changed by TypeScript #64461: write /*elided*/ any at the repeated type rather than report the new TS5088. This applies to .vibe and unchanged .ts files alike. Other TS5088 checks and ordinary recursive-inference errors TS7022, TS7023 and TS7024 remain unchanged. The recursive builder illustrates the boundary.

Amendment: native overwrite protection (2026-11-02)

Decided detail 281 replaces detail 280: compatibility sidecars are emit outputs like .d.ts. An include source/sidecar pair selects the source and allows silent regeneration. Explicit files inputs retain native TS5055 protection: exit 1 and unchanged declaration bytes, including under vibe.sidecarDir. There is no generated marker, backup directory or warning. The independent compatibility write schedule of detail 283 remains unchanged.

  • Amended 2026-11-02: written schema types (foldkit-9, accepted by Will). For either ordinary schema X form, one written, nongeneric type X = typeof X.Type in the same scope replaces the synthesized alias, in either source order. Its type query resolves to that schema value. Preserve each declaration’s own JSDoc, order and export status as on the native constant/type pair; native comment/visibility emit options still apply. Other type-name collisions remain errors.

  • Clarified 2026-11-03: matcher type scope and initialization. The ambient SymbolConstructor augmentation is program-wide under TypeScript, so .ts files in a program containing the provider see it too. The property has the same unique symbol type as the runtime's customMatcher export. A type reference does not execute initialization; VIP-31 separately requires the first synchronous initialization dependency in emitted VibeLang modules using the key. TypeScript modules, including VIP-40 bodies, keep ordinary dependency order and arrange initialization before using the key, normally with import "vibelang-runtime" first. Seeing the ambient type is not evidence that initialization has run.