Skip to content

TypeScript binding for cel-rust (CEL, the Common Expression Language), used for policy enforcement, configuration validation, and business rule evaluation.

Notifications You must be signed in to change notification settings

kevinmichaelchen/cel-typescript

Repository files navigation

cel-typescript

A TypeScript binding for the Common Expression Language (CEL) using cel-rust. This project provides a Node.js native module that allows you to use CEL in your TypeScript/JavaScript projects.

What is CEL?

Common Expression Language (CEL) is an expression language created by Google that implements common semantics for expression evaluation. It's a simple language for expressing boolean conditions, calculations, and variable substitutions. CEL is used in various Google products and open-source projects for policy enforcement, configuration validation, and business rule evaluation.

Installation

npm install @kevinmichaelchen/cel-typescript

Node.js 18 or later is required.

Usage

See the full language definition for a complete overview of CEL.

There are two ways to use CEL expressions in your code:

One-Step Evaluation

For simple use cases where you evaluate an expression once:

import { evaluate } from "@kevinmichaelchen/cel-typescript";

// Basic string and numeric operations
await evaluate(
  // 1️⃣ Provide a CEL expression
  "size(message) > 5",

  // 2️⃣ Provide a context object
  { message: "Hello World" },
); // true

// Complex object traversal and comparison
await evaluate("user.age >= 18 && user.preferences.notifications", {
  user: {
    age: 25,
    preferences: { notifications: true },
  },
}); // true

Compile Once, Execute Multiple Times

For better performance when evaluating the same expression multiple times with different contexts:

import { CelProgram } from "@kevinmichaelchen/cel-typescript";

// Compile the expression once
const program = await CelProgram.compile(
  "items.filter(i, i.price < max_price).size() > 0",
);

// Execute multiple times with different contexts
await program.execute({
  items: [
    { name: "Book", price: 15 },
    { name: "Laptop", price: 1000 },
  ],
  max_price: 100,
}); // true

await program.execute({
  items: [
    { name: "Phone", price: 800 },
    { name: "Tablet", price: 400 },
  ],
  max_price: 500,
}); // true

// Date/time operations using timestamp() macro
const timeProgram = await CelProgram.compile(
  'timestamp(event_time) < timestamp("2025-01-01T00:00:00Z")',
);
await timeProgram.execute({
  event_time: "2024-12-31T23:59:59Z",
}); // true

Note

Performance measurements on an Apple M3 Pro show that compiling a complex CEL expression (with map/filter operations) takes about 1.4ms, while execution takes about 0.7ms. The one-step evaluate() function takes roughly 2ms as it performs both steps.

Consider pre-compiling expressions when:

  • You evaluate the same expression repeatedly with different data
  • You're building a rules engine or validator that reuses expressions
  • You want to amortize the compilation cost across multiple evaluations
  • Performance is critical in your application

For one-off evaluations or when expressions change frequently, the convenience of evaluate() likely outweighs the performance benefit of pre-compilation.

Architecture

This project consists of three main components:

  1. cel-rust: The underlying Rust implementation of the CEL interpreter, created by clarkmcc. This provides the core CEL evaluation engine.

  2. NAPI-RS Bindings: A thin Rust layer that bridges cel-rust with Node.js using NAPI-RS. NAPI-RS is a framework for building pre-compiled Node.js addons in Rust, providing:

    • Type-safe bindings between Rust and Node.js
    • Cross-platform compilation support
    • Automatic TypeScript type definitions generation
  3. TypeScript Wrapper: A TypeScript API that provides a clean interface to the native module, handling type conversions and providing a more idiomatic JavaScript experience.

Note

Only ESM is supported by this package.

Native Module Structure

The native module is built using NAPI-RS and provides cross-platform support:

  • Platform-specific builds are named cel-typescript.<platform>-<arch>.node (e.g., cel-typescript.darwin-arm64.node for Apple Silicon Macs)
  • NAPI-RS generates a platform-agnostic loader (index.js) that automatically detects the current platform and loads the appropriate .node file
  • The module interface is defined in index.d.ts which declares the types for the native module
  • At runtime, the TypeScript wrapper (src/index.ts) uses the NAPI-RS loader to dynamically load the correct native module
  • This structure allows for seamless cross-platform distribution while maintaining platform-specific optimizations

Package Size and Platform Support

The npm package is relatively large (~37 MB unpacked) because it includes pre-compiled native binaries for all supported platforms:

  • macOS (x64, arm64)
  • Linux (x64, arm64)
  • Windows (x64)

However, when you install this package, npm will only extract the .node file for your platform. For example:

  • On Apple Silicon, only cel-typescript.darwin-arm64.node (~7.4 MB) is used
  • On Windows, only cel-typescript.win32-x64.node is used
  • On Linux, only cel-typescript.linux-x64.node or cel-typescript.linux-arm64.node is used

This is sometimes a pattern for packages with native bindings. For comparison:

A Note on Tree-Shaking

Tree-shaking (dead code elimination) primarily works with JavaScript modules and doesn't affect native binaries. The .node files are loaded dynamically at runtime based on the platform, so tree-shaking can't eliminate unused platform binaries during build time.

However, the JavaScript/TypeScript portion of this package is tree-shakeable. For example, if you only use evaluate() and not CelProgram, a bundler like webpack or Rollup can exclude the unused code.

How it Works

When you build this project:

  1. The Rust code in src/lib.rs is compiled into a native Node.js addon (.node file) using NAPI-RS
  2. The TypeScript code in src/index.ts is compiled to JavaScript
  3. The native module is loaded by Node.js when you import the package

The build process creates several important files:

  • .node file: The compiled native module containing the Rust code
  • index.js: The compiled JavaScript wrapper around the native module
  • index.d.ts: TypeScript type definitions generated from the Rust code

Contributing

cel-rust submodule

This project uses git submodules for its Rust dependencies. To get started:

# Clone with submodules
git clone --recurse-submodules https://github.com/clarkmcc/cel-typescript.git

# Or if you've already cloned the repository
git submodule update --init --recursive

After cloning:

npm install    # Install dependencies
npm run build  # Build the project
npm test       # Run tests

License

[License information here]

About

TypeScript binding for cel-rust (CEL, the Common Expression Language), used for policy enforcement, configuration validation, and business rule evaluation.

Topics

Resources

Stars

Watchers

Forks

Packages

No packages published