Skip to main content
A Lion environment is a plain JavaScript object whose keys are strings and whose values can be anything the host runtime can express. When the evaluator encounters a string expression it consults the environment: if a matching key exists the bound value is returned, otherwise the string returns as-is. This single mechanism covers variable lookup, standard-library dispatch, and host-object interop simultaneously — no separate namespacing or import system is required.

Structure

Internally, environments are held in Effect Ref cells and chained into a lexical scope tree:
  • A toplevel environment has a bindingsRef but no parent. It is created by run from the caller-supplied bindings object.
  • An inner environment has both a bindingsRef and a parent pointer. Lambda calls create an inner environment that extends the enclosing scope with the parameter bindings.
getBinding walks the chain from innermost outward, returning Option.some(value) at the first match or Option.none() when no binding exists at any level.

String resolution

When the evaluator sees a string it calls evaluateReference:
export const evaluateReference = (name: string) =>
  pipe(
    getService(LionEnvironmentService),
    Effect.flatMap((environment) => getBinding(environment, name)),
    Effect.map(Option.getOrElse(() => name))
  );
If getBinding returns Option.none(), Option.getOrElse(() => name) substitutes the original string. This means an unbound string is not an error — it evaluates to itself. That makes bare string literals safe inside object values or quote expressions without any extra quoting.

The stdlib default environment

stdlib is a pre-built environment that ships with @lionlang/core. Every entry is namespaced as <module>/<name>:
["number/add", 1, 2]
["string/concat", "lion", "ized"]
["array/map", ["array/make", 1, 2, 3], ["lambda", ["x"], ["number/add", "x", 1]]]
Passing stdlib directly to run gives access to all built-in modules without any additional setup.

Extending the environment

Spread stdlib and add your own keys to inject custom values, host objects, or plain functions:
import { Effect } from "effect";
import { run } from "@lionlang/core/evaluation/evaluate";
import { stdlib } from "@lionlang/core/modules";

const env = {
  ...stdlib,
  price: 100,
  taxRate: 0.08,
  clamp: (min: number, max: number, value: number) =>
    Math.min(max, Math.max(min, value)),
};

await Effect.runPromise(
  run(["number/add", "price", ["number/multiply", "price", "taxRate"]], env)
);
// => 108

await Effect.runPromise(run(["clamp", 0, 100, 150], env));
// => 100
When "price" is evaluated as a string it resolves to 100. When "clamp" is evaluated it resolves to the JavaScript function, which is then called by the function-call handler with the evaluated arguments.
Any JavaScript value — plain objects, class instances, native functions, or even other Effect computations — can live in the environment. Lion makes no assumptions about the shape of bound values beyond the fact that callable values are invoked as Lion functions when they appear as the head of an array expression.

Global mutation with define

The define special form writes a new binding into the toplevel environment, regardless of how deeply nested the current call is:
["define", "answer", 42]
After this evaluates, any subsequent expression in the same run call (or in a begin block) can reference "answer" and receive 42. Lambdas created after the define also observe the new binding because they look up names at call time through the environment chain.
define mutates the toplevel environment via a Ref.update. It is designed for sequential programs (e.g. inside begin) where ordering is explicit. Concurrent use of define in parallel effects is safe at the Ref level but may produce ordering that is hard to reason about.

Lexical scope in lambdas

When lambda is evaluated it captures the current LionEnvironmentService value as enclosedEnvironment. Each call then creates a new inner environment extending that captured scope:
const enclosedEnvironment = yield* getService(LionEnvironmentService);
// ...
const environment = yield* makeEnvironment(paramsEnvironment, enclosedEnvironment);
This gives Lion lambdas true lexical scope: they close over the bindings visible at definition time while still observing global define mutations made after creation (because the global Ref is shared through the chain).