Skip to content

Expressions

Expressions are JavaScript, evaluated per row. One expression, one value — like a spreadsheet formula, and with the same trust model in the browser.

They appear in seven places: a field mapping in formula mode, a match rule’s value, the value a Search (or Link) lookup searches for, a row filter, a module’s Which rows rule, an update guard (…update only when), and a field’s Must be rule. A helper library supplies the functions they call.

row The source row, by column name.
helpers Functions from the built-in library and your organisation’s own.
rec Records written earlier in this row, by module singular.
old, next Update guards only: the matched record, and what would be written.

rec is the default; the name is configurable per organisation, so yours may differ. See Curating modules.

row["Account No"]
row.Company

Bracket notation for anything with a space or punctuation in it, which is most real-world column headings. Such a column can also be read by a safe identifier, with underscores in place of the spaces and punctuation: row.Account_No.

A column’s own name always reads that column. If the file has both Account No and Account_No, row.Account_No is the Account_No column, and row["Account No"] reads the other. Two headings that make the same identifier, such as Campaign Source and Campaign-Source, share it, so read each by its own name in brackets.

A missing column is undefined, not an error, so row.Nickname || '' does what you would expect.

The built-in library, available everywhere:

Helper Does
helpers.join(sep, ...values) Join the non-blank values with a separator.
helpers.split(value, delimiter, index?) Split and return one trimmed part.
helpers.date(value, inputFormat?) Parse a date to the destination’s format. Numbers are read in your organisation’s order, or the order given ('DD/MM/YYYY'); a month written by name or abbreviation is read in any order — Sep 30, 2026, 30 September 2026, 2026-Sep-30 — with no order needed, and a two-digit year comes last, as in 30-Sep-26. A weekday in front and a time after are dropped, keeping the date as written. A two-digit year follows the spreadsheet convention: 00–29 is the 2000s, 30–99 the 1900s. A value it cannot read comes back unchanged, and that includes numbers that are a day only in the other order (03/25/2026 read day first): it is not read the other way. Handed back into a date field, such a value is then read in your organisation’s order. Validate stops on a formula’s column where numeric dates come out beside year-month-day ones, which is how it shows; a column where every value was handed back is not caught, so check a formula’s dates on a few rows.
helpers.num(value, fallback?, decimal?) Parse a number leniently — currency symbols and codes, thousands separators, brackets and a trailing minus for negatives, and exponential notation as a spreadsheet writes it. Each value is read by the decimal mark it shows: 1.234,56 and 1,234.56 are both 1234.56, and 150,00 € is 150. A value that shows none of its own, such as 1.234 (a thousand-odd with a decimal comma, a little over one with a point), is read by decimal — ',' or '.' — else by your organisation’s, from its time zone. A number — what arithmetic or another helper gave — is kept as it is, and a field filled by a formula that gives a number gets that number, whatever your organisation’s decimal mark. A blank cell is 0. A value it cannot read is refused rather than turned into a number the file never held. The Validate step lists the rows, wherever the formula is — filling a field, matching records, or looking one up — and a run says which row it was: a field formula leaves that field out, a match rule carries on as if nothing matched (so the row may create a duplicate), and a lookup takes its If none match — leaving the link empty, unless it says to skip the record or ask. Two things are only reported once the import runs, because neither can be tried beforehand: a field formula that also reads rec.* (those records do not exist until the run makes them), and the row filter — a row it cannot read is dropped, and Source says the filter errored and shows the first one rather than listing the rows. Pass a fallback — helpers.num(row.Qty, 0) — to say what an unreadable or blank cell should become.
helpers.bool(value) True for yes / y / true / t / 1 / on / -1, case-insensitive; false for anything else.
helpers.proper(value) Title-case each word.
helpers.upper(value) / helpers.lower(value) Change case.
helpers.trim(value) Trim surrounding whitespace.
helpers.digits(value) Keep only the digits.
helpers.phone(value, country?) A phone number in international form, +61412345678: one format for every row. A number written with its country code keeps it (+64 21 443 8820, 0011 44 20 7946 0958); one written without takes the country given — 'NZ' or '+64' — else your organisation’s, from its time zone (Settings → General), and drops that country’s trunk prefix, as 0412 345 678 becomes +61412345678. The numbers of every country with a numbering plan of its own are read, by Google’s libphonenumber metadata. A number it cannot make valid, such as one with a letter or an extension in it or a length or leading digits no number of its country has, is refused: the Validate step lists the rows.
helpers.fallback(value, default) The value, or a default when it is blank.
helpers.map(value, mapping) Translate through a lookup object; unknown values pass through.
helpers.today() Today’s date as YYYY-MM-DD, in your organisation’s time zone (Settings → General).

Your organisation can add its own, under its own namespace — see Helper libraries.

helpers.join(', ', row.City, row.State, row.Postcode)
helpers.phone(row.Phone)
helpers.fallback(row.Owner, 'Unassigned')

helpers.phone gives every number one form, E.164 (+61412345678), which CRMs and diallers take everywhere. A number it cannot make valid is refused, never guessed at: Validate lists the rows, and an import leaves that field out of them rather than writing a number your file did not hold. It refuses a number with anything but digits, spaces, dashes, dots and brackets in it (a word, an extension, a #), one whose length or leading digits no number of its country has, one whose country code no country has, and one written without a country code when no country is given and your organisation has no time zone to take one from.

The numbers of every country with a numbering plan of its own are read, by Google’s libphonenumber metadata: each country’s trunk prefix, the prefix it dials other countries with, and the lengths and leading digits its numbers have. Those are what it checks a number by, not every digit pattern a national plan allows, so a number of a plausible length and start is taken. So 0412 345 678 and 412 345 678 (what a spreadsheet makes of it) are +61412345678 in Australia, +39 (0)6 6982 1234 keeps the 0 an Italian number holds, and 0011 44 20 7946 0958 is a UK number dialled from Australia. So is 0044 20 7946 0958: a number that starts 00 and a country code is read as international wherever it is read, since that is how spreadsheets write one, whatever the country itself dials out with.

A number written with its country code — after a +, or after the international prefix of the country it is read for — keeps it. One written without takes the country you give (helpers.phone(row.Mobile, 'NZ'), or '+64'), else your organisation’s. Beside a number written after a +, the country given is not read, so a stale value in a Country column refuses nothing.

rec — records written earlier in this row

Section titled “rec — records written earlier in this row”

When a row writes to several modules, an expression on a later module can read what the earlier ones produced:

rec.Account.id

A field the CRM fills in as it saves a record (a created time, a modified-by, an owner) is read back after the save when the CRM says it fills it, so a formula that reads one is worked out then and sees the real value, on a record the import made as on one it found. The record is read back once for the row’s after-save formulas, so a sibling formula worked out after the save sees what that read brought. A read-only field the CRM does not say it fills is otherwise read from what the import sent, which holds none for a record the import has only just made: the formula reads it empty, and Validate warns of one that reads such a field.

In a note. A formula into Note title or Note content reads the row’s records the same way, after the record is saved, and one that reads a field the CRM fills reads the saved record back first, with the read the after-save fields use (no read where it reads only rec.Account.id). A note’s formula that cannot be worked out fails its row: the note is not left out in silence.

Testing a record is not reading it. These only ask whether there is a record, and then name what they read of it, so the value goes into the new record as it is made:

rec.Account ? rec.Account.id : null
rec.Account && rec.Account.id
!rec.Account ? 'no account' : rec.Account.Account_Name
rec.Account != null ? rec.Account.id : null
rec.Account?.id
(rec.Account || {}).id

Reading a record whole waits for it to be saved. A formula that hands the record on could read any field of it, the ones your CRM fills in itself included, so Sloose saves the record, reads it back and fills the field afterwards:

JSON.stringify(rec.Account)
helpers.acme.describe(rec.Account)
rec.Account[row.Which]
rec.Account.valueOf() // a call on the record, not a field of it

That costs a read more for each row whose record it reads, and a write more wherever the formula has something to fill (none where the formula comes to nothing, or where the field already holds a value and the mapping fills only where empty: a link to a record saved later in the row does by default). The field is not in the record as it is created, so do not use such a formula for a field your CRM requires; and on a record the row found and updates, the field is written as the formula’s value, replacing what it held — a column your file fills does not stop updating because its formula mentions Created Time. The one default that fills only where the field is empty is a link to a record saved later in the row. A mapping written in a configuration repository says its own: patch.overwrite: 'if-absent' fills only where empty, and 'always' replaces; a patch that says nothing of overwrite replaces too, even on a link to a record saved later (only a mapping with no patch at all gets that link’s fill-only default). All of this is about a formula worked out after the save, which is decided by what the formula reads and not only by the save order: one that reads its own record’s id, a field your CRM fills (an auto-number, Created Time), a record as a whole, or a record saved later in the row. overwrite is honoured for every one of those, on a record the row makes and on one it finds and updates, so 'if-absent' keeps a value the record already holds even where the record it reads was saved first. A formula that reads only ordinary fields of records already saved, or of its own record, is worked out before the write instead: its value goes in the record as it is made, or in the update itself, which replaces what the record held, and overwrite has no say there. A record the row found and leaves takes no field filled after the save, and is not read back for one: under Skip it, which is the default, and where an update guard says no, the record is left as it is, and the row’s log says so. The match card and Validate say it before the run, so a field filled after the save is set by the row that creates a record and, with Update it, set, replacing what is there, on one a row finds. (A later row that names a company an earlier row made finds a record the import made, and leaves it too. A record the row leaves is read by later formulas as the CRM holds it, not as the file says; a module that produced no record at all is read from the file’s values, as it always was.)

Write the record with its module straight after it. rec.Account.Name, rec["Account"].Name and rec.Account?.Name name the record they read. A formula that gets to the records any other way (eval, its own arguments, rec handed to a function or kept in a variable, (rec).Account, rec[row.Which], text after a closing bracket that runs as code of its own) cannot be followed: Sloose could not say which of the row’s records it reads, or when it can run. Validate refuses it at the formula, and a run refuses it before anything is written, in the same words. The old spelling of an optional step, (rec.Account || {}).id, still names the field it reads.

Earlier means earlier. A record can be read once it has been written, so Sloose writes the module a field’s formula reads before the module that field is on. Where it cannot — a save order set by hand, or two modules that read each other — the field is filled in after the later record saves, one more write for the row, and validation says so rather than letting it pass silently.

The order is the connection’s module order, moved only where one of the row’s records needs another: a field’s formula reads it, or a lookup finds it by search on a field the file fills on that record’s module. It is shown on the Run step whenever such a link joins the modules. A lookup found by search cannot be filled in afterwards as a formula’s field is, so against an order set by hand, or among modules that search for each other in a ring, Validate stops the run rather than warn, where the searched module can make records for the file: a row whose record the import makes would find none. Where the searched module makes none (Updates only, with If several match making none either) it warns, since a record already in your CRM is found whichever is saved first — unless this import changes the value the lookup searches by: one it is writing in is not there until the record is saved, and one it is replacing (a renamed Account searched by its old name) is not there after. A search on a field the file does not fill is no such link: no record the file writes could be found by it, so nothing is ordered and nothing is stopped.

Outside a live run — in the formula editor, in a validation probe — rec yields a readable placeholder rather than throwing, so an expression still previews before there is anything to reference.

An update guard decides whether an update goes ahead:

next.Phone && next.Phone !== old.Phone

old is the record as it stands in the CRM. next is what would be written. This is how you say “do not blank a value because this file’s column is empty” or “do not downgrade a status”.

A single-select field is read, in next and in rec.<Module>.<Field> alike, as the option its list spells: where the file wrote customer for the option Customer, both read Customer, since that is what Sloose sends and what Zoho holds.

A guard that returns false downgrades the write to a skip. A guard that throws fails closed — the record is not updated. That is the safe direction: a broken guard should not wave writes through.

So does a guard that names a field the connection does not have (old.Locked__c where discovery lists no such field), by old or through rec.<Module>: the found record is read with the fields the connection holds, so that field would be empty, and Validate and the run both refuse it. Reading the record whole beside a named field does not make the field known. A destructure given the record directly (const { Locked__c } = old, or a function written in place and called with it) names its literal keys. An alias (const o = old), a computed key or a rest element counts as a whole read and names nothing: it is not refused on a guess.

row helpers rec old / next
Field mapping ✓ ✓ ✓ ·
Match rule value ✓ ✓ · ·
Search / Link value ✓ ✓ · ·
Row filter ✓ ✓ · ·
Module gate ✓ ✓ ✓ ·
Update guard ✓ ✓ ✓ ✓

A row filter cannot read rec. The file is filtered before any record exists, so the reference could never resolve. Use a module gate instead, which runs per module with earlier records available.

A match rule cannot read rec either: the match check runs before this row has written anything. Nor can the value a Search lookup searches for: the search runs before this row’s records exist.

All three are caught at validation rather than at the row.

Nothing is handed to an expression but the row, helpers and the records of this row: no other rows, and no clock beyond helpers.today(). Where Sloose runs one on its servers (to try a formula the AI wrote, or a helper library), it runs in a sandbox with no network and no storage. In your browser a formula is compiled as a spreadsheet formula is, in the page, so nothing there stops one reaching for what a page can reach. Write them as functions of the row and nothing else: that is what makes a formula’s behaviour something you can reason about from the file in front of you.

An expression that throws does not stop the run. What it does to the row depends on where it is:

  • a field formula: the field is left out of that row’s record, the row is still written, and the run says so as a warning;
  • a Which rows rule: that module’s record is held back for the row;
  • an update guard: the record is left as it is, since a guard that cannot be read does not let the update through;
  • a match rule’s value, or a search lookup’s: the run goes on as if nothing matched, and says the row may create a duplicate.

Each is said in the run’s log and against the row on the results grid.

An expression that does not compile is an error at validation — the run will not start.

Blank sends nothing. An expression returning null, undefined or '' leaves the field out of the record: a record the run creates has the field empty, and a record it updates keeps the value it had. That is often what you want and occasionally a surprise — a blank does not clear a field; the Validate step warns when a field will import empty on a large share of rows.

The f(x) button anywhere an expression is accepted opens the formula editor, which runs your expression against real rows from your file as you type. Use it — a formula that looks right and a formula that produces the right value on row 200 are different claims.

Where AI is configured, the editor’s Ask AI writes one from a question in plain English, and says why one fails on your rows. The expression is compiled and run against your samples before you see it, so what comes back at least executes — except an update guard that reads old or next, and an expression that reads records, which are compiled only because those exist only while an import runs (a row filter may not read records at all). See What the AI does.

Report a problem with this page

sloose.com