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"))
| 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). |
A celscript snippet is a single TypeScript file with two exports:
Context— atypedescribing the data the expression evaluates against. Drives autocomplete and type-checking inside the expression body.- Default export — the entry point. Wrap an arrow function in the
expr(...)marker andexport defaultit. The compiler walks to the default export, finds theexpr(...)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)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.
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.
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], .length → size(x) |
| Array macros | .some → exists, .every → all, .filter, .map, .includes → in, statically typed .concat → list + with scalar args wrapped as singleton lists, Array.isArray → type(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/sqrt → math.… (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 |
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 |
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.
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) |
The emitted CEL relies on three standard extension libraries being enabled on the host:
- strings —
trim,split,replace,contains,startsWith,endsWith,matches,substring - math —
math.least,math.greatest,math.abs,math.floor,math.ceil,math.round,math.trunc,math.sign,math.sqrt - bindings —
cel.bind(everyconstdeclaration, 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.
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_INTERPOLATION — undefined/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_ARRAY — val 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_FALLTHROUGH — case 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 |
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 :3000Pre-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/lastIndexOfshipped as theadvanced-stringspack. - Configurable rounding modes for
int()/uint()beyond the shippedHALF_UP(#106). .indexOf(x) !== -1 → .includes(x)ESLint autofix.- Cross-package
celFnhelper resolution — today helpers resolve within the compiled program's files; following npm dependencies is an open question.
Issues, design questions, and feature requests welcome.