Skip to content

Conversation

@hudlow
Copy link
Collaborator

@hudlow hudlow commented Dec 19, 2025

NOTE THIS IS A BREAKING CHANGE AND NEEDS TO BE CONSIDERED CAREFULLY

As I discussed with @timostamm and @srikrsna-buf, this PR eliminates the "overload" abstraction from the cel-es codebase. Instead, multiple functions (and methods) can have the same name, and each function is responsible for whether it matches a name and/or a set of arguments.

This way, the _&&_ and _||_ functions can still match on any set of arguments and handle short-circuiting in a conformant way — but other functions rely on standard argument-matching logic.

This also lays the groundwork for matching functions via overload_id (which will be necessary if evaluating a checked expression) and matching functions via parameter types instead of argument values.

This PR does introduce unique, generated overload IDs in a different way than #243, but it doesn't attempt to match the IDs to the cel-go implementation.

Unfortunately, the scope grew larger than I intended. If it's necessary, I can try to decompose the work further.

@hudlow hudlow requested a review from srikrsna-buf December 19, 2025 19:42
Copy link
Member

@srikrsna-buf srikrsna-buf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall I am quite happy with the simplification of removing overloads and fixing the method vs function problem. Left some comments, still reviewing the rest.

Comment on lines 274 to 276
narrowedByName(name: string): FuncRegistry;
narrowedByArgs(...args: [CelResult[]] | [CelResult, CelResult[]]): FuncRegistry;
// TODO: narrowedByParams(...args: [CelType[]] | [CelType, CelType[]]): FuncRegistry;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means we will create a new FuncRegistry for each lookup. Maybe we can avoid the allocation if we club the args and name and just return a Callable | undefined?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I simplified function registry, renamed it back to a dispatcher (to avoid the proto registry confusion), and added a lookup cache.

find(name: string) {
return this.functions.get(name);
}
withFallback(registry: FuncRegistry): FuncRegistry;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

User's will also see this I think we can expose a simpler interface.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I adjusted this to withFallbacks(Callable[]) — let me know what you think. The idea is to make the ordering explicit.

celFunc(olc.SIZE, [MAP], INT, (x) => BigInt(x.size)),

celFunc(opc.IN, [DYN, LIST], BOOL, inList),
celFunc(opc.IN, [DYN, MAP], BOOL, (n, h) => h.has(n as CelMapIndex)),
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@srikrsna-buf CelMapIndex is used here.

Comment on lines +50 to +51

causes(value: unknown, exprId?: bigint | number): CelError;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can use a function instead for this that is internal like the deleted celErrorMerge which is not exported from the package.

}
}

function isOfType<T extends CelType>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can move this to type.ts?

export interface Dispatcher {
find(name: string): CallDispatch | undefined;
}
export interface Callable<R extends CelType = CelType> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the interface is public via celFunc and celMethod I think it will be useful to add the doc comments back

export interface Dispatcher {
find(name: string): CallDispatch | undefined;
}
export interface Callable<R extends CelType = CelType> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure making the result generic is useful. We can simplify if we use CelType directly.


get parameters() {
return this._parameters;
export function celFunc<const P extends TypeTuple, const R extends CelType>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doc comments for celFunc


if (errors.length) return errors[0].causes(errors.slice(1));

return results as CelValue[];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should return unwrapped here

Comment on lines +160 to +166
const unwrapped = results.reduce((u, r, i) => {
return u.concat([
types ? unwrapResult(r, types[i], i + 1) : unwrapResult(r),
]);
}, [] as CelResult[]);

const errors = unwrapped.filter((r) => isCelError(r));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Using a regular loop we can do it in one go

}

call(id: number, args: CelResult[]): CelResultFromType<R> {
const target = unwrapResult(args[0], this._target);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There could be a subtle bug here unwrapResult expects a CelResult which can never be undefined but here the args can be an empty list and args[0] will return undefined. I think we should replicate matchArgs to be safe

Comment on lines +240 to +242
readonly #callables: Callable[];
readonly #nameCache: Map<string, Dispatcher | undefined> = new Map();
readonly #overloadIdCache: Map<string, Callable | undefined> = new Map();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We deliberately don't use JS private fields to support older runtimes. This is anyway an internal class we can just use TS private fields

}
eval(ctx: Activation): CelResult {
const vals = coerceToValues(this.values.map((x) => x.eval(ctx)));
const vals = unwrapResultTuple(this.values.map((x) => x.eval(ctx)));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't want to unwrap here because we want the Any unwrap to be lazy

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.

3 participants