Skip to content

feat: context-aware functions and generic context typing#40

Open
danfry1 wants to merge 3 commits into
mainfrom
feat/issue-33-context-aware-functions
Open

feat: context-aware functions and generic context typing#40
danfry1 wants to merge 3 commits into
mainfrom
feat/issue-33-context-aware-functions

Conversation

@danfry1
Copy link
Copy Markdown
Owner

@danfry1 danfry1 commented May 13, 2026

Summary

Closes #33.

Two coordinated additions, shipped together so the typed API lands polished from day one:

  1. addContextFunction(name, fn) — register a function that reads the evaluation context as its first parameter, alongside the existing addFunction for pure functions.
  2. bonsai<TCtx>() — make the factory generic over context type, with end-to-end type safety through evaluate, evaluateSync, compile, addContextFunction, and BonsaiPlugin.
interface AppContext {
  currentUserId: string
  perms: readonly string[]
}

const app = bonsai<AppContext>()

app.addContextFunction('lookupCurrentUserTier', async (ctx) => {
  const row = await db.users.findById(ctx.currentUserId)
  return row?.tier ?? 'free'
})

app.addContextFunction('hasPermission', (ctx, action) =>
  ctx.perms.includes(String(action)))

await app.evaluate(
  'lookupCurrentUserTier() == "pro" && hasPermission("admin")',
  { currentUserId: 'u_123', perms: ['admin', 'write'] },
)

Design highlights

  • No this binding. Context flows in as a regular first parameter; arrow functions work without ceremony.
  • Shallow-frozen context copy. Created lazily once per top-level evaluation and shared across all context-function calls in that evaluation. Functions cannot mutate the caller's context object or each other's state.
  • Shared namespace. Pure (addFunction) and context-aware (addContextFunction) functions share one namespace. Last registration wins. isContextFunction(name) introspects the kind.
  • BonsaiContext constraint. TCtx extends object, so bonsai<number>() is a compile error.
  • Conditional EvaluationContextArgs. When TCtx has required fields, the context argument is required at call sites; when it has only optional fields (or is the default), context stays optional. Catches missing-context bugs at compile time without breaking the untyped default path.
  • Conditional plugin typing. use<TPluginCtx>(plugin: TCtx extends TPluginCtx ? BonsaiPlugin<TPluginCtx> : never) lets plugins typed against a minimal context apply cleanly to instances with a wider context shape, and rejects mismatched plugins at compile time.
  • Discriminated callable resolution. resolveCallable returns { kind: 'pure' | 'context', fn }. Both sync and async evaluator paths share the same call-site logic so they cannot diverge.

Backward compatibility

Fully backward compatible. Every existing call site keeps working:

  • bonsai() (untyped) defaults TCtx to Record<string, unknown> and context stays optional everywhere.
  • addFunction, addTransform, evaluate, evaluateSync, compile, BonsaiPlugin, CompiledExpression keep their existing public shapes via defaulted generics.
  • No changes to the lexer, parser, compiler, security policy, or autocomplete.

Performance

Bundled three function-related registry maps into a single cached Bindings snapshot to keep the evaluator signature compact. The snapshot is rebuilt only when a registration changes, so the hot path pays zero per-call cost.

Measured on this branch (Apple Silicon):

Benchmark Throughput
Pure function call id(1) 13.7M ops/sec
Single context function call 12.1M ops/sec
Six context calls in one expression 24M context-function calls/sec (amortised freeze)
Realistic hasPermission("write") with 4-field context 9.9M ops/sec
evaluateSync literal (full path) 27M ops/sec (unchanged)
evaluateSync property access 12.8M ops/sec (unchanged)

The lazy freeze means pure-function-only evaluations never pay the shallow-copy cost.

Tests

41 new tests covering:

  • Basic invocation, multi-arg, no-context defaults
  • Isolation (frozen context, no mutation of original, shared snapshot across calls in one evaluation, fresh snapshot per evaluation, pure functions never receive context)
  • Async invocation, parallel-safe context, async-in-sync rejection
  • Compile cache compatibility (sync + async)
  • Namespace and introspection (overwrite both directions, hasFunction, removeFunction, listFunctions, isContextFunction)
  • Reference-error suggestions that span both registries
  • Error semantics (missing-context fields resolve to undefined, user errors propagate as-is, async errors via returned promise, sync rejection of promise-returning context functions reports the function name)
  • Plugin integration (typed and untyped plugins, narrower-than-instance plugin contexts)
  • Integration with pipe transforms, conditional expressions, and lambdas
  • Type-level surface (instance-generic inference, parameter contravariance, @ts-expect-error for wrong shapes, required-context enforcement, non-object generic rejection, plugin-context narrowing)

Credits

Thanks to @jaenyf for raising #33 and contributing PR #34.

Test plan

  • bun run typecheck clean
  • bun run test — 816 passing (was 796 baseline)
  • bun run lint — 0 warnings
  • bun run build clean
  • bun run bench — no regression on existing benchmarks
  • New benchmarks/context-functions.bench.ts documents the feature's perf envelope

Adds addContextFunction(name, fn) so registered functions can read the
evaluation context as their first parameter, alongside the existing
addFunction for pure functions. Makes bonsai<TCtx>() generic over
context type, propagating end-to-end through evaluate, evaluateSync,
compile, addContextFunction, and BonsaiPlugin.

The context type generic is constrained to BonsaiContext (object) so
primitive generics are rejected at compile time. EvaluationContextArgs
makes the context argument required at call sites when TCtx has
required fields and optional otherwise, catching missing-context bugs
without breaking the untyped default path. Plugins typed against a
narrower context apply cleanly to wider-context instances; mismatched
plugins are rejected at compile time.

Context functions receive a shallow-frozen copy of the context lazily
created once per top-level evaluation and shared across all
context-function calls within that evaluation. Pure-function call sites
are unaffected; benchmarks show no regression.

Closes #33.

Co-authored-by: jaenyf <95911656+jaenyf@users.noreply.github.com>
@danfry1 danfry1 marked this pull request as ready for review May 13, 2026 21:04
CI activates oxlint-tsgolint (already in bun.lock as an optional peer
of oxlint), which adds 8 extra rules that were not running locally.
This commit clears the resulting violations: 4 mine and 3 pre-existing
across the codebase, plus the matching u-flag regex warnings.

- Drop unused type imports (ValidationResult, TransformFn, FunctionFn)
  from src/index.ts; they were already re-exported via export type.
- Rename _shared -> sharedInstance in src/index.ts.
- Rename __dirname -> scriptDir in scripts/check-package.ts.
- Rename _typed / _untyped -> typed / untyped in the type tests.
- Add the u flag to every regex literal across src, scripts, and tests.
Updates the website docs, landing page, and LLM-readable docs for the
new addContextFunction API and bonsai<TCtx>() context-type generic.

- New "Context-aware functions" section in docs.html with usage, frozen
  context semantics, namespace behavior, and the isContextFunction
  introspection helper.
- Typed-generics section expanded with bonsai<TCtx>() examples and a
  note that required context fields make the context argument required
  at call sites.
- Instance Methods table gains addContextFunction and isContextFunction
  with updated descriptions for the shared pure/context namespace.
- Common Tasks card surfaces addContextFunction alongside addFunction.
- llms.txt and llms-full.txt mirror the new API for LLM consumers.
- Landing page feature card mentions context-aware functions.

Playground is unchanged. It evaluates expressions against ad-hoc
context only, so no registration UI is involved.
@jaenyf
Copy link
Copy Markdown

jaenyf commented May 14, 2026

Hi Dan, many thanks ! This implementation is far better than my quick one.
If I may suggest some things from my modest perspective :

  • I'm not sure segregating "pure" and "contextualized" methods is a good approach here. I mean, on one hand we duplicates the checks to resolve a function by its name (expected unique) and on the other hand we allow 2 functions with the exact same name to exist in parallel. Also, i think having 2 methods named addFunction and addContextFunction brings the opposite of a clear API. Not ideal IMHO, but it's just my 2 cents.
  • Forcing shallow freezing of the context may seems a good idea to give some measure of protection but is still fragile as it does not guaranty deep children mutation for instance. So, still IMHO, the benefits here for the user is very mitigated and may come at the cost of some performance issue if heavily invoked. For these 2 reason, I would either personally delegate the context freeze responsibility to the user. Either by assuming we just have a frozen context or by forcing it through a purposed delegate. And not to talk about the scenarii where the user would want the context to be mutated. Maybe another approach could be to let the user chose how he want to use bonsai-js (that was my idea with the options passed to addMethod) ? Just my 2 cents here.

Appart from this, my current usage scenario should be fulfilled with this current implementation and I should be happy with it. So thanks again.

Have a lovely rest of Ascension Day

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Access evaluated context from an added function

2 participants