Skip to content

Feat/expr number overload#66

Merged
thedevyashsaini merged 3 commits into
mainfrom
feat/expr-number-overload
Jun 3, 2026
Merged

Feat/expr number overload#66
thedevyashsaini merged 3 commits into
mainfrom
feat/expr-number-overload

Conversation

@thedevyashsaini

Copy link
Copy Markdown
Member

No description provided.

@thedevyashsaini thedevyashsaini merged commit 5c1ec0e into main Jun 3, 2026
2 of 3 checks passed
@greptile-apps

greptile-apps Bot commented Jun 3, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces a new ExprValue branded type and extends biller.expr() with a number overload, requiring all non-primitive debit expressions to pass through biller.expr() before being assigned to Debit. The Debit type is narrowed from number | PriceExpr to number | TagExpr | ExprValue, so bare DSL calls like mul(...) are no longer directly assignable.

  • New ExprValue brand: PriceExpr & { [EXPR_BRAND]: true } enforced via toExprValue() (compile-time only, no-op at runtime), used as the return type for all biller.expr() overloads.
  • Number overload for expr(): biller.expr(250) now wraps the integer as an AmountExpr, but unlike the direct-number path it skips the Zod nonnegative() guard — biller.expr(-100) passes client-side validation while debit: -100 does not.
  • Stale JSDoc examples: Several doc-block code snippets in event.ts still show bare mul(...) calls as valid debit values, which will produce type errors if copy-pasted after this change.

Confidence Score: 3/5

Safe to review further, but the number overload introduces a client-side validation gap that allows negative cent amounts to silently reach the backend.

The number overload in biller.expr() creates an AmountExpr that bypasses the nonnegative() check applied to the direct-number path in the Zod union. A caller writing biller.expr(-100) gets a valid ExprValue that passes all SDK-side validation, while debit: -100 is rejected immediately. This is a present inconsistency on the changed code path, not a theoretical edge case.

packages/scrawn/src/core/scrawn.ts (number overload and its interaction with Zod validation) and packages/scrawn/src/core/types/event.ts (stale JSDoc examples that no longer compile).

Important Files Changed

Filename Overview
packages/scrawn/src/core/pricing/types.ts Introduces the ExprValue branded type and toExprValue cast; validates expression shape but does not guard against negative amounts in AmountExpr
packages/scrawn/src/core/scrawn.ts Adds expr(amount: number) overload that wraps raw numbers into AmountExpr; all overloads now return ExprValue; negative integers silently bypass the nonnegative Zod check
packages/scrawn/src/core/types/event.ts Narrows Debit from `number
packages/scrawn/src/core/pricing/index.ts Re-exports new ExprValue type; no logic changes
examples/basic-usage-expr.ts Updated all debit values to go through biller.expr(); adds new examples for raw-number and tag-wrapping overloads
examples/ai-sdk-wrapper-usage.ts Wraps outputDebit expression in biller.expr() to satisfy the new Debit type
examples/ai-token-stream-expr-usage.ts Wraps all debit expressions in biller.expr() to satisfy the updated Debit type

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[User provides debit value] --> B{Type?}
    B -- "raw number (e.g. 500)" --> C["Debit: number\n✅ nonnegative() checked by Zod"]
    B -- "biller.tag('X')" --> D["Debit: TagExpr\n✅ accepted directly"]
    B -- "biller.expr(string)" --> E["toExprValue → ExprRef\n✅ name format validated"]
    B -- "biller.expr(number)" --> F["toExprValue → AmountExpr\n⚠️ nonnegative NOT checked"]
    B -- "biller.expr(PriceExpr)" --> G["toExprValue → PriceExpr passthrough\n✅ structure validated"]
    B -- "mul / add / div (raw OpExpr)" --> H["❌ No longer assignable to Debit"]
    C --> I[Zod DebitSchema validation]
    D --> I
    E --> I
    F --> I
    G --> I
    I --> J[normalizeDebit → NormalizedDebit]
    J --> K[gRPC RegisterEvent]
Loading

Comments Outside Diff (1)

  1. packages/scrawn/src/core/types/event.ts, line 155-162 (link)

    P2 Stale JSDoc examples no longer type-check

    The EventPayload doc block still shows debit: mul(biller.tag('PREMIUM_CALL'), 3) as a valid example (line 159). Since Debit was narrowed from number | PriceExpr to number | TagExpr | ExprValue, a bare OpExpr from mul() is no longer assignable and will produce a type error if copy-pasted. The same stale pattern appears in the AITokenUsagePayload examples (lines 411–412) which show inputDebit: mul(biller.tag('GPT_INPUT_RATE'), inputTokens()) — also now invalid.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Reviews (1): Last reviewed commit: "fix: wrap inline expressions with biller..." | Re-trigger Greptile

Comment on lines +349 to 351
if (typeof value === "number") {
return toExprValue({ kind: "amount", value } as PriceExpr<TTags>);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Negative amounts bypass the nonnegative debit guard

biller.expr(-100) produces { kind: "amount", value: -100 }, an ExprValue that passes isValidExpr() because validateAmount() only rejects non-finite and non-integer values — negative numbers are explicitly left to "the backend". Meanwhile, passing -100 directly as debit: -100 is rejected immediately by the Zod union's z.number().nonnegative() branch. The two paths that reach the same semantic result are validated inconsistently, so a negative raw-amount expression silently escapes the client-side guard that exists for the direct-number case.

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.

1 participant