Skip to content

Repository files navigation

celscript

A restricted subset of TypeScript that compiles to Google CEL (Common Expression Language).

Write policies, predicates, and side-effect-free expressions as typed TypeScript — get real IDE support, structural refactoring, and tsc checking — then compile down to CEL for use in a CEL runtime.

import { expr } from "@mike-north/celscript-core";

export type Context = {
  user?: { role?: string; tags?: string[] };
  req: { method: string };
  defaults: { role: string };
};

export default expr<Context>(
  (ctx) =>
    (ctx.user?.role ?? ctx.defaults.role) === "admin" ||
    (ctx.req.method === "GET" && (ctx.user?.tags ?? []).some((t) => t === "reader"))
);

compiles to

(user == null ? defaults.role : (user.role == null ? defaults.role : user.role)) == "admin" ||
(req.method == "GET" && (user == null ? [] : (user.tags == null ? [] : user.tags)).exists(t, t == "reader"))

Packages

Package Purpose
@mike-north/celscript-core Runtime entry-point marker (expr(...)) and shared types. The only package end-users import.
@mike-north/celscript-compiler Walks a typed TS AST and emits CEL. Pure API with no writes; Node host mode builds a real ts.Program so the emitter can use the TypeChecker.
@mike-north/celscript-analyzer Extracts the outer-branching decision tree of a zone for visualization.
@mike-north/celscript-eslint-plugin Surfaces compiler diagnostics as ESLint findings; ships celscript/no-object-assign with an autofix to object spread.
@mike-north/celscript-ts-plugin TypeScript Language Service plugin: in-editor diagnostics and CEL-on-hover.
@mike-north/celscript-chip-ast Simplified "chip" AST for rendering zone expressions as structured UI blocks (used by the demo's visual editor).
@mike-north/celscript-cel-eval-wasm cel-go compiled to WASM — the reference CEL evaluator used by the conformance suites and the demo.
@mike-north/celscript-demo Live Next.js demo (Monaco editor + React Flow tree).

Snippet format

A celscript snippet is a single TypeScript file with two exports:

  1. Context — a type describing the data the expression evaluates against. Drives autocomplete and type-checking inside the expression body.
  2. Default export — the entry point. Wrap an arrow function in the expr(...) marker and export default it. The compiler walks to the default export, finds the expr(...) call, and compiles the arrow body.
import { expr } from "@mike-north/celscript-core";

export type Context = { foo: string };

export default expr<Context>((ctx) => ctx.foo === "bar");

The wrapped expression carries a JS evaluator for local testing:

import policy from "./policy.ts";

policy.eval({ foo: "bar" }); // JS evaluator — same value as the compiled CEL
policy.cel; // compiled CEL string (populated at build time)

Embedding celscript in your product

See docs/embedding-guide.md for the end-to-end adopter path: authoring surface setup, build-time compilation, shipping the CEL string with its required runtime extensions, validation responsibilities, versioning expectations, and a testing strategy.

Language coverage

The normative specification lives in spec/ — start with spec/README.md for the reading guide and conformance statement. For day-to-day reference and examples, the user-facing catalog is in docs/supported-features.md; the ECMA-262-aligned audit table is in docs/ecma262-coverage.md; the rationale prose is in docs/principles.md.

A celscript snippet is a single TypeScript expression compiled into CEL. The compiler accepts three broad families of source forms.

Direct-mapping forms

Pass straight through with at most a renamed call site or a normalized literal.

Category Forms
Literals number (decimal, hex 0xFF, octal 0o17, binary 0b101, separators 1_000_000, exponents), string, boolean, null, array […], object {…}, template literals
Operators + - * / %, < <= > >=, === !==, && || !, ternary ?:, in over object; bitwise & | ^ ~ << >> (via math.bit*)
Member access obj.field, obj["k"], arr[i], .lengthsize(x)
Array macros .someexists, .everyall, .filter, .map, .includesin, statically typed .concat → list + with scalar args wrapped as singleton lists, Array.isArraytype(x) == list
String methods .trim, .split, .replace, .startsWith, .endsWith, .contains, .matches, .substring, .charAt, .indexOf, .lastIndexOf, .slice.substring; Unicode case conversion (.toLowerCase / .toUpperCase) is rejected because CEL only exposes ASCII case helpers
Math namespace arity- and type-constrained Math.min/max/abs/floor/ceil/round/trunc/sign/sqrtmath.… (math extension)
Type casts type-constrained String(x)string(x), Number(x)double(x), Boolean(x)bool(x); explicit int(x), double(x), uint(x)
Branded value types timestamp(iso) / duration(d) / bytes(b) from @mike-north/celscript-core lower to the CEL casts of the same name. Comparison, arithmetic, and accessor methods (.getFullYear(), .getHours(), etc.) on Timestamp / Duration pass through to the corresponding CEL method form. Cross-brand === (e.g. Timestamp === string) rejects with CEL010 STATIC_TYPE_MISMATCH.
Bindings const NAME = …; lowers to cel.bind; chains and use inside macro callbacks
Destructuring const { a, b } = obj;, const [a, b] = arr;, IIFE param patterns — expanded into per-field cel.bind steps with renames + defaults
User-defined helpers celFn((p) => …) declarations inline at every call site as cel.bind(p, <arg>, <body>). Supports multi-parameter helpers, transitive calls, and the tail-call recursion fold (below); rejects non-tail recursion and non-celFn external calls
Tail-call recursion fold celFn helpers whose body matches if (xs.length === 0) return BASE; const [h, ...t] = xs; if (P) return EARLY; return SELF(t, …); lower at the call site to xs.exists(h, P) or xs.all(h, …). Supports extra value-typed parameters (bound via cel.bind around the macro) and function-typed parameters (the inline arrow or celFn helper passed at the call site is inlined inside the predicate). Recursion outside the recognized shape rejects with CEL020 / CEL021.
Spread […, ...arr], {…, ...obj} — flattened statically when the operand is a literal, lowered to + concat/merge when it's a variable; non-computed __proto__ object keys are rejected
Type-only annotations x as T, <T>x, x satisfies T are stripped from the CEL output

Type-aware forms

The compiler consults the TypeScript checker for operand types and disambiguates JS overloads or rejects what's statically nonsensical. Full power in Node and in the TypeScript server / ESLint plugins; degrades gracefully in the in-browser playground.

Feature Behavior
.includes() dispatch string receiver → s.contains(sub); array receiver → v in arr
Disjoint === rejection CEL010 STATIC_TYPE_MISMATCH when operand types share no values (e.g. ctx.n === "x" with n: number)
has() lift "field" in obj with a typed receiver → has(obj.field)
Template-literal cast pruning drops string(…) around already-string operands
Template-literal interpolation guard CEL005 when undefined/void, list, or map is interpolated into a template — CEL has no equivalent value or cast
for-in array guard rejects for-in over typed string[] / [A, B] — JS gives index strings, CEL .exists gives values
Numeric-operator guard rejects typed non-numeric operands for + - * / % instead of relying on JavaScript numeric coercion

Pattern lifts

Idiomatic TypeScript patterns the compiler recognizes and rewrites to a different CEL shape than a literal translation would produce.

Form CEL output
a?.b, a?.[i], chains a == null ? null : a.b for trivial receivers (no bind — CEL is side-effect-free); non-trivial roots bind once: cel.bind(__t1, E, __t1 == null ? null : __t1.b)
a ?? d a == null ? d : a for trivial a; cel.bind(__t, E, __t == null ? d : __t) otherwise
a?.b ?? d (seam fused) the fallback inlines at each ?. guard and a final null check: a == null ? d : (a.b == null ? d : a.b)
((x, y) => body)(a, b) (IIFE) cel.bind(x, a, cel.bind(y, b, body))
for (const x of/in iter) { if (cond) return <bool>; } return <opposite>; iter.exists(x, cond) or iter.all(x, !cond) (! unwrapped for the natural .all idiom)
switch (DISCR) { case L1: return E1; …; default: return ED; } DISCR == L1 ? E1 : … : ED — chain of ternaries; default required, literal labels only. Pure fallthrough (case L1: case L2: return E;) ORs the labels: (DISCR == L1 || DISCR == L2) ? E : …
typeof x === "T" ("string", "boolean", "number") type(x) == <celtype> (with int/double disjunction for "number")
Object.keys(m).{length,some,every,includes} size(m) / m.exists / m.all / k in m — receiver-side rewrite for order-insensitive map-key checks

The replacement story for x! (non-null assertion, rejected because CEL has no throw to enforce the runtime claim) is a?.b ?? fallback.

Selected rejections (with the conceptually-identical replacement)

celscript refuses any form whose defining semantic property can't survive CEL. Full table with rationale lives in docs/principles.md. Highlights:

Form Use instead
let, var const
==, != (loose) ===, !==
x! a?.b ?? fallback
'k' in [array] arr.includes(k)
Object.assign({}, …) object spread { ...a, ...b } (the ESLint plugin auto-fixes inside zones)
Regex literal /foo/ matches(s, "foo")
instanceof, typeof, delete, void, new, ++/--, compound assignment comparison / explicit construction / refactor to use the value
class, function*, async, await, try/catch, tagged template, dynamic import() — (refactor; CEL has no analogue)

CEL runtime requirements

The emitted CEL relies on three standard extension libraries being enabled on the host:

  • stringstrim, split, replace, contains, startsWith, endsWith, matches, substring
  • mathmath.least, math.greatest, math.abs, math.floor, math.ceil, math.round, math.trunc, math.sign, math.sqrt
  • bindingscel.bind (every const declaration, IIFE lift, optional-chain step, and ?? uses this)

Example (cel-go):

env, _ := cel.NewEnv(
  cel.Variables(...),
  ext.Strings(),
  ext.Math(),
  ext.Bindings(),
)

The validation strategy for keeping these runtime requirements aligned with authoring-time and compile-time feedback is specified in spec/30-authoring-surfaces.md.

Diagnostic codes

Stable across the compiler, ESLint plugin, and TS server plugin (defined in packages/compiler/src/diagnostics.ts):

Code Meaning
CEL001 DISALLOWED_SYNTAX — any rejected form not covered by a more specific code
CEL002 LOOSE_EQUALITY== / !=
CEL003 NON_WHITELISTED_CALL — unknown function or namespaced call
CEL004 UNSUPPORTED_ARRAY_METHOD — method with no CEL equivalent (.find, .reduce, …)
CEL005 TEMPLATE_INTERPOLATIONundefined/void, list, or map operand in a template literal
CEL006 REGEX_LITERAL
CEL007 UNBOUND_IDENTIFIER
CEL008 INVALID_ZONE_SHAPE — missing export default expr(…) or malformed block body
CEL009 IN_OVER_ARRAYval in [array literal]
CEL010 STATIC_TYPE_MISMATCH=== between statically disjoint operand types
CEL011 NON_CELFN_HELPER — call to a cross-module function that isn't wrapped in celFn(...)
CEL012 HELPER_BODY_INVALID — a celFn(...) helper's body uses unsupported syntax, or recursion
CEL013 HELPER_PARAMETER_INVALID — a celFn(...) helper uses rest / default / destructured / function-typed parameters, or arg-count mismatch
CEL015 UNREACHABLE_CASE_FALLTHROUGHcase labels falling through into default (where they're unreachable)
CEL020 RECURSION_NOT_TAIL_CALL — a celFn(...) helper recurses in non-tail position (inside an expression)
CEL021 RECURSION_SHAPE_UNSUPPORTED — a celFn(...) helper recurses in tail position but the surrounding shape isn't the foldable shrinking-list pattern
CEL016 DESTRUCTURE_REST_UNSUPPORTED...rest in an object or array destructuring pattern
CEL017 DESTRUCTURE_NESTED_UNSUPPORTED — nested binding pattern; use two flat consts
CEL018 DESTRUCTURE_COMPUTED_KEY — computed key in an object destructuring pattern
CEL019 DESTRUCTURE_IN_MACRO_CALLBACK — destructured macro callback parameter or for-of loop variable
CEL022 TYPESCRIPT_TYPE_ERROR — TypeScript itself rejected a typed zone expression (e.g. a missing Context property); no CEL is emitted
CEL023 PACK_REQUIRED — a governed form was used without an enabler; the message lists every exit (safe form, domain assertion, or extension pack)
CEL024 RESERVED_CEL_IDENTIFIER — a binding name or context field is a CEL reserved word (as, loop, namespace, …); rename it

Development

pnpm install
pnpm build            # turbo build, all packages
pnpm test             # vitest across all packages
pnpm lint             # eslint across all packages
pnpm --filter demo dev # start Next.js demo on :3000

Status

Pre-release. Every feature listed above is in the v0.1 normative surface — see spec/. Sourcemaps, celFn helpers, the tail-call recursion fold, the int/double numeric discipline, and branded Timestamp / Duration / Bytes types have all shipped (see the per-item status notes in spec/90-future-work.md). Active follow-ups, per that canonical list:

  • Extension packs for full-Unicode string operations — case mapping and whitespace trimming beyond the shipped ASCII tier (#98); strings16.length / indexOf / lastIndexOf shipped as the advanced-strings pack.
  • Configurable rounding modes for int() / uint() beyond the shipped HALF_UP (#106).
  • .indexOf(x) !== -1 → .includes(x) ESLint autofix.
  • Cross-package celFn helper resolution — today helpers resolve within the compiled program's files; following npm dependencies is an open question.

Issues, design questions, and feature requests welcome.

Releases

Packages

Contributors

Languages