Para

Para Lang

An optional .pts / .pjs syntax over Para Lib. Adds reactive bindings (signal / effect / ~> / ->), pipelines (|>), error chaining (..! / ..&), match expressions, schema declarations with :: boundary validation, sync / synced server-authoritative values, integer ranges, pure / memo declarators, and defer / arena blocks. Compiles to standard JavaScript at parse time.

The reactive forms desugar to imports from @lyku/para-signals; the rest desugar to plain JS. The .pts compiler today lives inside ParaBun; a standalone @lyku/para-transpile (npm, pre-release) covers the core operator surface — it powers the playground — with full-surface parity in progress.

Syntax (Para Lang)

Signals and effects

A signal declaration creates a reactive cell. Bare reads inside a tracked context (an effect, a derived, a when block, or another signal's RHS) compile to .get(). Bare writes compile to .set(). A signal whose initializer reads other signals is auto-promoted to a derived value.

signal count = 0;
signal doubled = count * 2;       // derived; recomputes when count changes

effect { console.log(doubled); }  // runs once now, again on each change

count++;                          // count.set(count.get() + 1)

Edge-triggered handlers

A when block fires its body once on each false→true transition of the predicate. when not fires on the true→false transition. The predicate is tracked the same way an effect body is.

signal score = 0;

when score >= 100 { unlockAchievement("century"); }
when not online   { showOfflineBanner(); }

Reactive bindings

A ~> B desugars to effect(() => { B = A; }), an assignment that stays in sync. A -> fn desugars to effect(() => { fn(A); }), a call binding. Both are shorthand for the common single-statement effect.

signal name = "world";
name ~> document.title;          // title tracks name
name -> console.log;             // logs on every change

Ranges and pipelines

a..b is an exclusive integer range; a..=b is inclusive. The pipeline operator |> threads a value through a sequence of unary calls.

for (const i of 0..n) work(i);
const evens = 0..=20 |> filter(i => i % 2 === 0);

const out = pixels |> map(p => p * 1.2) |> clamp(0, 255);

Promise operators

Three sibling operators cover the whole Promise.prototype chain symmetrically. ..> is .then, ..! is .catch, ..& is .finally — same precedence, same handler shape, composable in any order. Bare arrow handlers compose without parens (each arrow body terminates at the next chain op), and a leading . in ..> / ..! position is sugar for "method/property on the resolved value": ..> .json() means ..> (_) => _.json(). (No leading-dot sugar for ..& — finally callbacks have no value to bind.) await binds tighter than the dotted operators, so wrap the chain to await it.

const data = await (
  fetch(url)
    ..> .json()                    // .then  — call .json() on the response
    ..! .message                    // .catch — extract error message
    ..& () => spinner.hide()        // .finally — runs always
);

Parallel awaits

parallel is the answer to const [a,b,c,d,e] = await Promise.all([f,g,h,i,j]) — the positional-array shape where reordering one side without the other is a silent bug, and where every long name appears twice. Two forms: a statement form that hoists names directly (each appears once), and an expression form that returns the bag (chainable with ..!).

// statement — names appear exactly once, hoisted into scope
parallel let user     = fetchUser(id),
             posts    = fetchPosts(id),
             comments = fetchComments(id);
// each binding can have its own ..! for per-item error handling:
parallel let user     = fetchUser(id)     ..! defaultUser,
             posts    = fetchPosts(id)    ..! [],
             comments = fetchComments(id) ..! [];

// expression — returns a Promise of the resolved object, chainable
const bundle = await parallel { user: fetchUser(id), posts: fetchPosts(id) };
const data   = await parallel { user:, posts:} ..! err => fallbackBundle;

Pattern matching

match is an expression. Literal arms compile down to a switch jump table, | unions become fall-through cases, _ is the default. Ok(x) / Err(e) arms destructure Para's tagged results with a switch on the discriminant — no chained typeof checks.

const msg = match status {
  200       => "ok",
  400 | 404 => "client error",
  500       => "server error",
  _         => "unknown"
};

Schemas and validation

schema declares a shape once and yields three things: a runtime validator (.parse returning a Result), a static type, and a JSON Schema 2020-12 document (.schema). schema X from EXPR ingests an existing JSON Schema instead of the DSL. On a parameter, :: opts into validation at the boundary — the body sees a parsed value or the call throws. Recursive shapes just reference the declaration's own name; the reference compiles to a JSON-Schema $ref, with cyclic-graph and depth capabilities available as cyclic / schema(depth: n) modifiers.

schema User {
  id: int,
  email: Email,
  age: int(0..150)?
}

// boundary gate: handler body sees a validated User
function handler(req:: User, ctx) {
  return req.email;
}

// self-reference compiles to { $ref: "#Comment" }
schema Comment = {
  type: "object",
  properties: {
    body:    { type: "string" },
    replies: { type: "array", items: Comment }
  },
  required: ["body"]
};

Synced values

A sync declaration binds a server-authoritative value into a read-only reactive cell: every envelope off the wire is parse-gated through the schema, reconciled by (schema_version, sequence), applied to the cell, and the subscription is disposed on unmount. The world writes the cell; the component reads it. :: gates through a schema (the default), a single : is the visible trusted opt-out (TS type only, no parse gate), and synced NAME = args is the full-control form whose args go straight to synced(key, opts). Beyond keyed channels, the from source tiers by what the server can know: a typed query(...) has a knowable read-set, so it stays live automatically (scalar or collection, re-keying when values in the spec change); opaque server code cannot, so its refresh policy (every / on / once) is syntactically mandatory — and the expression is escape-analyzed out of the client build entirely. The pull mirror is derived NAME :: SCHEMA = EXPR: client-initiated, refetching when its inputs change, every response gated. Component-scoped (.pui) today; everything lowers to plain synced() / querySignal() calls from @lyku/para-sync and @lyku/para-signals.

// read-only replica — the server writes, the component reads
sync user :: User from `user:${id}`

// typed scalar query — LIVE via server read-sets; re-keys when `id` changes
sync me :: User from query({ where: u => u.id == id })

// opaque server code — refresh policy is mandatory, expression never ships to the client
sync stats :: Stats from server db.slowAggregate(orgId) every 30000

// trusted opt-out: TS type only, no parse gate
sync flags : FeatureFlags from "flags:global"

// full control: args go straight to synced(key, opts)
synced cart = `cart:${id}`, { stream: cartStream, seed };

// the pull mirror — refetches on input change, response gated by Post
derived found :: Post = api.search(q)

Purity

pure marks a function side-effect free, and the claim is checked at parse time — ambient globals (console, fetch, setTimeout, …) and writes to outer state in a pure body are compile errors, not lint warnings. The keyword itself strips from the output; what survives is a guarantee tooling can lean on (folding, hoisting, memoization, ParaBun's auto-parallelization).

pure fun distance(a, b) {
  const dx = a.x - b.x;
  const dy = a.y - b.y;
  return Math.sqrt(dx * dx + dy * dy);
}

Decimal literals

0.1 + 0.2 !== 0.3 keeps biting people. The Nd literal suffix produces a Decimal with exact arithmetic — BigInt-backed coef × 10^exp internally, no floating-point roundoff. JS doesn't allow operator overloading so arithmetic is explicit method calls (.plus, .minus, .times, .dividedBy); division takes { precision, roundingMode }.

0.1d.plus(0.2d).eq(0.3d);                              // true (the headline)
1d.dividedBy(3d, { precision: 20 }).toString();        // "0.33333333333333333333"
100d.dividedBy(8d).toString();                         // "12.5" — exact

const tax   = price.times(0.0825d);
const total = price.plus(tax);

Compilation

Para files (.pts) are parsed by ParaBun's transpiler. (Mainline Bun doesn't recognize the syntax — the parser additions are part of the ParaBun fork.) The output is standard JavaScript with a handful of imports from para:* module specifiers.

On ParaBun, those specifiers resolve to built-in modules. On any other host (browser, Node, Bun, Deno, Cloudflare Workers, …) alias them to the matching @lyku/para-* npm packages in your bundler.

// vite.config.ts
import { defineConfig } from "vite";

export default defineConfig({
  resolve: {
    alias: [{ find: /^para:(.*)$/, replacement: "@lyku/para-$1" }],
  },
});

The same one-line alias works for esbuild, webpack, and rollup. See the install guide for the variants.

$npm install @lyku/para-signals @lyku/para-parallel @lyku/para-pipeline
$parabun build src/main.pts --outdir dist/

ParaBun owns the full .pts surface (schemas, .pui) and remains the canonical build host; the standalone @lyku/para-transpile (npm, pre-release) covers the core operator surface for non-ParaBun hosts.

Examples

Three worked projects, one per host environment. Each is a complete project with file layout, source, and build commands.