Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/docs-formula-returntype.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

docs: formula guides declare a formula result type via `returnType` (not the forbidden `type`); CEL source on `expression`.
51 changes: 26 additions & 25 deletions content/docs/guides/business-logic.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -386,8 +386,8 @@ Formula fields are CEL. Use ternary `cond ? a : b` for conditionals and
// Tiered pricing
Field.formula({
label: 'Discount Tier',
type: 'text',
formula: `
returnType: 'text',
expression: `
record.amount > 1000000 ? "Platinum" :
record.amount > 500000 ? "Gold" :
record.amount > 100000 ? "Silver" : "Bronze"
Expand All @@ -397,8 +397,8 @@ Field.formula({
// Status indicator
Field.formula({
label: 'Health Score',
type: 'text',
formula: `
returnType: 'text',
expression: `
record.is_active && record.days_since_contact < 30 ? "Healthy" :
record.days_since_contact < 90 ? "At Risk" : "Critical"
`,
Expand All @@ -411,49 +411,50 @@ Field.formula({
// Days until close
Field.formula({
label: 'Days to Close',
type: 'number',
formula: 'daysBetween(record.close_date, today())',
returnType: 'number',
expression: 'daysBetween(record.close_date, today())',
})

// Contract end in 30 days
Field.formula({
label: 'Expiring Soon',
type: 'boolean',
formula: 'record.status == "active" && daysBetween(record.end_date, today()) <= 30',
returnType: 'boolean',
expression: 'record.status == "active" && daysBetween(record.end_date, today()) <= 30',
})

// Age in days
Field.formula({
label: 'Age (Days)',
type: 'number',
formula: 'daysBetween(today(), record.created_date)',
returnType: 'number',
expression: 'daysBetween(today(), record.created_date)',
})
```

### Financial Calculations

```typescript
// Gross margin
// Gross margin (computes a number; a formula's type is always 'formula' —
// declare the value type with returnType, and decimal places with scale)
Field.formula({
label: 'Gross Margin %',
type: 'percent',
formula: 'record.revenue > 0 ? ((record.revenue - record.cost) / record.revenue) * 100 : 0',
returnType: 'number',
expression: 'record.revenue > 0 ? ((record.revenue - record.cost) / record.revenue) * 100 : 0',
scale: 2,
})

// Weighted pipeline
Field.formula({
label: 'Weighted Amount',
type: 'currency',
formula: 'record.amount * (record.probability / 100)',
returnType: 'number',
expression: 'record.amount * (record.probability / 100)',
scale: 2,
})

// Total with tax
Field.formula({
label: 'Total with Tax',
type: 'currency',
formula: 'record.subtotal * 1.0825', // 8.25% tax
returnType: 'number',
expression: 'record.subtotal * 1.0825', // 8.25% tax
scale: 2,
})
```
Expand All @@ -464,22 +465,22 @@ Field.formula({
// Full name
Field.formula({
label: 'Full Name',
type: 'text',
formula: 'record.first_name + " " + record.last_name',
returnType: 'text',
expression: 'record.first_name + " " + record.last_name',
})

// Uppercase
Field.formula({
label: 'Code',
type: 'text',
formula: 'upper(record.sku)',
returnType: 'text',
expression: 'upper(record.sku)',
})

// Has a company email
Field.formula({
label: 'Internal',
type: 'boolean',
formula: 'endsWith(lower(record.email), "@example.com")',
returnType: 'boolean',
expression: 'endsWith(lower(record.email), "@example.com")',
})
```

Expand Down Expand Up @@ -609,8 +610,8 @@ flows: [
// Formula: Lead score
Field.formula({
label: 'Lead Score',
type: 'number',
formula: `
returnType: 'number',
expression: `
(record.rating == "hot" ? 30 : record.rating == "warm" ? 20 : 10) +
(record.annual_revenue > 1000000 ? 25 : 0) +
(record.number_of_employees > 500 ? 20 : 0) +
Expand Down
14 changes: 13 additions & 1 deletion content/docs/guides/formula.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export const Invoice = ObjectSchema.create({
titleFormat: tmpl`Invoice {{record.invoice_no}} – {{record.customer.name}}`,
fields: {
total: Field.formula({
type: 'currency',
returnType: 'number',
expression: F`record.subtotal + record.subtotal * record.tax_rate`,
}),
po_number: Field.text({
Expand All @@ -103,6 +103,18 @@ export const Invoice = ObjectSchema.create({
});
```

<Callout type="warn">
**A formula field's `type` is always `formula`** — that is the key the runtime
evaluates (`objectql` computes a formula virtual field from its `expression`
only). Do **not** pass `type: 'currency' | 'number' | …` to `Field.formula` — it
is rejected by the typed `FieldInput` and, untyped, would override `type:'formula'`
so the field silently never computes. To declare what the formula returns, set
**`returnType`** (`'number' | 'text' | 'boolean' | 'date'`); it is inferred and
stamped automatically by the AI build path, and consumers (dashboard measures,
formatting) read it instead of re-parsing the expression. The CEL source is the
**`expression`** key (the `formula` alias is normalized to it on save).
</Callout>

The three CEL tagged-template helpers — `cel`, `F` (formula alias), `P`
(predicate alias) — are interchangeable; pick whichever reads best at the
call site. `cron` and `tmpl` produce their respective envelopes.
Expand Down