VibeLang
TypeScript-shaped code with the compiler-level semantics for building reliable tools and agents.
npm install --save-dev vibelang
npx vibelang initpnpm add --save-dev vibelang
pnpm exec vibelang initbun add --dev vibelang
bunx vibelang initTypeScript compatible
Native when ready
Comptime
Durable execution
Overview
VibeLang starts with TypeScript, then makes the minimal number of intentional changes needed to supercharge it for building reliable programs in the AI era.
99% TypeScript
VibeLang is almost TypeScript. The vast majority of its syntax is indistinguishable from TypeScript. You can import .ts files directly and use any TypeScript or JavaScript dependency. On the TypeScript target, VibeLang compiles to ordinary TypeScript that fits into the ecosystem you already use.
import { clamp } from "lodash-es"
export function normalizeConfidence(score: number): number {
return clamp(score, 0, 1)
}import { clamp } from "lodash-es"
export function normalizeConfidence(score: number): number {
return clamp(score, 0, 1)
}import { clamp } from "lodash-es"
export function normalizeConfidence(score: number): number {
return clamp(score, 0, 1)
}For ordinary code, there is nothing to learn or rewrite—the three versions are identical.
Typesafe Errors as Values
One of VibeLang's only changes to TypeScript is that errors are treated as strongly typed values that must be handled.
class NotFound extends Error {
constructor(readonly id: string) {
super(`User not found: ${id}`)
}
}
function getUser(
users: Map<string, User>,
id: string,
): Result<User, NotFound> {
const user = users.get(id)
if (user === undefined) throw new NotFound(id)
return user
}
const user = getUser(users, id).match({
ok: user => user,
error: error => error.match({
NotFound: () => User.guest(),
}),
})class NotFound extends Error {
constructor(readonly id: string) {
super(`User not found: ${id}`)
}
}
function getUser(users: Map<string, User>, id: string) {
const user = users.get(id)
if (user === undefined) throw new NotFound(id)
return user
}
let user: User
try {
user = getUser(users, id)
} catch (error) {
if (error instanceof NotFound) user = User.guest()
else throw error
}
// NotFound is still invisible in getUser's type.import { Effect } from "effect"
class NotFound extends Error {
constructor(readonly id: string) {
super(`User not found: ${id}`)
}
}
function getUser(users: Map<string, User>, id: string) {
const user = users.get(id)
return user === undefined
? Effect.fail(new NotFound(id))
: Effect.succeed(user)
}
const user = getUser(users, id).pipe(
Effect.catchAll(() => Effect.succeed(User.guest())),
)The compiler sees Result<User, NotFound> and lowers the plain return and Error throw into its two variants. The caller must propagate or handle that Result. error.match is exhaustive over the known Error union.
Result is an ordinary must-use value. There is no generator ceremony or universal effect interpreter; functions remain eager with ordinary stacks.
Dependencies in the function signature
VibeLang uses an Effect-inspired context package instead of adding dependency-injection syntax. Its API looks like an ordinary library, but the compiler tracks every accessed service in the function's type signature.
import { Context } from "vibelang/context"
abstract class Clock extends Context {
abstract now(): Date
}
abstract class AuditLog extends Context {
abstract write(event: AuditEvent): Result<void, AuditError>
}
function issueToken(user: User): Result<Token, AuditError> {
const clock = Clock.context()
const audit = AuditLog.context()
const token = Token.create(user, clock.now())
audit.write({ type: "token.issued", userId: user.id }).unwrap()
return token
}
// Inferred context: Clock | AuditLoginterface Clock {
now(): Date
}
interface AuditLog {
write(event: AuditEvent): void
}
function issueToken(
user: User,
clock: Clock,
audit: AuditLog,
): Token {
const token = Token.create(user, clock.now())
audit.write({ type: "token.issued", userId: user.id })
return token
}import { Context, Effect } from "effect"
class Clock extends Context.Tag("Clock")<
Clock,
{ readonly now: () => Date }
>() {}
class AuditLog extends Context.Tag("AuditLog")<
AuditLog,
{ readonly write: (event: AuditEvent) => Effect.Effect<void, AuditError> }
>() {}
function issueToken(
user: User,
): Effect.Effect<Token, AuditError, Clock | AuditLog> {
return Effect.gen(function* () {
const clock = yield* Clock
const audit = yield* AuditLog
const token = Token.create(user, clock.now())
yield* audit.write({ type: "token.issued", userId: user.id })
return token
})
}The inherited context() method is compiler-aware. It adds the capability class to the function's inferred context channel, and that requirement propagates through ordinary calls until a context or layer provides it. The function stays eager and callers never pass a context argument by hand.
Comptime can produce real types
VibeLang can run deterministic code during compilation—and the result can be a real TypeScript type. Here, a project-defined comptime function turns a tracked model file into a type the rest of the program uses normally.
import { comptime } from "vibelang:comptime"
const model = comptime(JSON.parse(embed("./account.model.json")))
const Account = comptime(deriveType(model))
function displayName(account: Account) {
return `${account.name} (${account.plan})`
}// TypeScript cannot return a type from an ordinary build-time function.
// Declare this by hand or add a separate code-generation step.
type Account = {
id: string
name: string
plan: "free" | "pro"
}
function displayName(account: Account) {
return `${account.name} (${account.plan})`
}import { Schema } from "effect"
const Account = Schema.Struct({
id: Schema.String,
name: Schema.String,
plan: Schema.Literal("free", "pro"),
})
type Account = typeof Account.Type
function displayName(account: Account) {
return `${account.name} (${account.plan})`
}Effect can derive a type from an Effect schema, but the schema must become the source of truth. VibeLang comptime can execute an ordinary, project-defined derivation and return a type. The compiler tracks every input, caches the result, and reports a compile error if evaluation depends on runtime state.
Files become typed modules
Compiler-known files participate in the same type system and incremental build graph. VibeLang takes the direct-import ergonomics of Bun's loaders and extends them with compile-time types and dependency tracking.
JSON that keeps its literals
Ordinary TypeScript can import JSON, but values such as strings and numbers are widened. VibeLang's const form preserves the file as a deeply readonly literal value.
import config from "./agent.config.json" with {
type: "json",
mode: "const",
}
// "reasoning" — not string
type Model = typeof config.model
// readonly ["search", "write"] — not string[]
type Tools = typeof config.tools// agent.config.ts
// Move the data into TypeScript to keep its exact values.
export const config = {
model: "reasoning",
tools: ["search", "write"],
} as const// Effect does not change TypeScript's JSON import inference.
// The exact-value workaround is still a TypeScript source file.
export const config = {
model: "reasoning",
tools: ["search", "write"],
} as constMarkdown is a prompt module
A Markdown file imports as a string at compile time. It can go straight into a model request without runtime filesystem access, a raw-loader suffix, or a declaration file.
import systemPrompt from "./system.md" with { type: "text" }
const response = (await model.generate({
system: systemPrompt,
prompt: question,
})).unwrap()import { readFile } from "node:fs/promises"
const systemPrompt = await readFile(
new URL("./system.md", import.meta.url),
"utf8",
)
const response = await model.generate({
system: systemPrompt,
prompt: question,
})import { FileSystem } from "@effect/platform"
import { Effect } from "effect"
const response = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const system = yield* fs.readFileString("./system.md")
return yield* model.generate({ system, prompt: question })
})Rust and Zig import like source modules
Existing Node-API packages built with napi-rs remain ordinary npm dependencies. Local Rust and Zig sources can also be imported directly; VibeLang drives the foreign compiler and generates checked bindings for the selected TypeScript, native, or Wasm target.
import { createCanvas } from "@napi-rs/canvas"
import { rank } from "./rank.rs" with { type: "rust" }
import { tokenize } from "./tokenize.zig" with { type: "zig" }
const canvas = createCanvas(512, 512)
const ranked = rank(tokenize(source))import { createCanvas } from "@napi-rs/canvas"
import { rank } from "./native/rank.js"
// napi-rs packages work, but local Rust needs a Cargo build,
// a generated .node binary, a JS wrapper, and declarations.
const canvas = createCanvas(512, 512)
const ranked = rank(tokens)import { Effect } from "effect"
import { rank } from "./native/rank.js"
// Effect can wrap the native call, but it does not build the binding.
const ranked = Effect.try({
try: () => rank(tokens),
catch: (cause) => new NativeError({ cause }),
})This follows Bun's proven loader model: text files can import as strings, .node files use its N-API loader, and Node-API is its stable native boundary. VibeLang adds generated source bindings and records source locations, toolchain versions, and dependency edges so diagnostics, watch mode, and remote caching stay correct across every format.
Concurrency keeps standard call syntax
Async functions remain ordinary async functions. Authored VibeLang consumes Promises only with await; a static or library combinator starts concurrent work, and Result.all combines expected outcomes.
const results = await Promise.all([
loadProfile(userId),
loadActivity(userId),
])
const [profile, activity] = Result.all(results).unwrap()const [profile, activity] = await Promise.all([
loadProfile(userId),
loadActivity(userId),
])
// A rejection does not cancel or join the remaining work.import { Effect } from "effect"
const load = Effect.all([
loadProfile(userId),
loadActivity(userId),
])VibeLang bans Promise instance .then(), .catch(), and .finally() in authored source. Structured cancellation and child ownership belong in an explicit library combinator rather than custom parser syntax. Effect provides those guarantees through Effect values and its fiber runtime.
Make execution durable where it matters
Durability is explicit. Actions define persistence boundaries, while the compiler lowers functions passed to durable(...) into typed, resumable execution plans.
import { durable } from "vibelang:flows"
abstract class Research extends Action<
(input: ResearchInput) => Result<Report, ResearchFailed>
> {}
abstract class Publish extends Action<
(input: PublishInput) => Result<Publication, PublishFailed>
> {}
const Release = durable(function Release(
input: ReleaseInput,
): Result<Publication, ResearchFailed | PublishFailed> {
const report = Research.run({ topic: input.topic }).unwrap()
return Publish.run({ report })
})async function release(input: ReleaseInput) {
const report = await research({ topic: input.topic })
return publish({ report })
}
// Persistence, retries, idempotency, and resume state need a
// workflow SDK or application-specific storage around this function.import { Effect } from "effect"
const release = (input: ReleaseInput) =>
Effect.gen(function* () {
const report = yield* research({ topic: input.topic })
return yield* publish({ report })
})The compiler derives success/Error schemas, plan edges, and worker contracts from each Action's Result signature. Completed Actions can be replayed after interruption without rerunning work that already succeeded. Effect adds typed composition and retry policies, but an Effect program is not durably journaled across process restarts by itself.
TypeScript today, native when ready
VibeLang can emit TypeScript for immediate compatibility, then optionally compile portable parts of the same program to native code or Wasm. The compiler checks the complete dependency graph and explains which call, package, or capability still requires the TypeScript runtime.
This gives teams a gradual path to native deployment: start inside an existing TypeScript codebase, move boundaries when they become valuable, and avoid a ground-up rewrite.
Features
VibeLang is designed to provide these capabilities out of the box:
- TypeScript compatible — import TypeScript packages, adopt VibeLang incrementally, and compile back to TypeScript
- Optional native compilation — move from TypeScript to native code gradually instead of committing to a rewrite
- Comptime type generation — derive validators, codecs, and real types without maintaining a parallel schema language
- Durable programs — turn explicit Action and Flow boundaries into persistent, distributed execution plans
- Typed Results — use must-use
Result<A, E>values and exhaustive Error matching across synchronous and asynchronous functions - Capability-based dependencies — make databases, clocks, filesystems, and other services visible in function types
- Typed asset imports — load JSON, Markdown, MDX, SQL, GraphQL, Zig, and Rust through compiler-tracked loaders
- Concurrency without Promise chains — use typed workers, static/library joins,
await, and cancellation without a fiber runtime
Built for tools and agents
Reliable agents need more than a tool-call protocol. They need explicit authority, recoverable failures, runtime validation, deterministic compilation, and durable work that can resume after interruption.
VibeLang makes those concerns part of one language model. Generated TypeScript runs with an explicit function surface, platform access is capability-based, and Actions or Flows add durability only where the program asks for it.
Read the Agent Library guide or start with Why VibeLang?.
Help shape the language
VibeLang is in active design. The syntax, compiler architecture, and core semantics are being specified before the production toolchain is built. This is the stage where concrete feedback can change the language at the lowest cost.
Review the specification or join the project on GitHub.