Common Expression Language
MyDataHelps supports a powerful Common Expression Language (CEL), which is used in survey Calculated Fields to do things such as:
- Calculate a score, which can include survey answers and custom fields
- Classify a score or answer into a text string
- Randomize participants
Introduction
The MyDataHelps CEL is a user-friendly expression language which works similarly to spreadsheet formulas and supports both numeric and text operations. It is an implementation of the open source Common Expression Language, extended with custom variables providing access to participant and survey data.
Quick Start Guide
CEL makes it simple to write calculations for things like BMI, date calculation, and scoring as in these examples.
// Calculate BMI from weight (kg) and height (m)
weight / (height * height)
// Calculate age from birthdate
dateDiff(today(), birthdate, "years")
// Average of three pain scores
mean([pain1, pain2, pain3])
// Check if pain level is severe
painLevel > 7
// Categorize BMI
bmi < 18.5 ? "Underweight" :
bmi < 25 ? "Normal weight" :
bmi < 30 ? "Overweight" : "Obese"
Expression Basics
CEL offers a predictable range of operators supporting numeric and text operations similar to spreadsheet formulas.
Numbers and Math
5 + 3 // Addition → 8
10 - 4 // Subtraction → 6
6 * 7 // Multiplication → 42
15 / 3 // Division → 5
17 % 5 // Remainder → 2
Comparing Values
age >= 18 // Greater than or equal
score == 100 // Equal to
status != "inactive" // Not equal to
weight < previousWeight // Less than
Membership Operations
"Headache" in symptoms // Value in multiple-choice answer
arm in ["TreatmentA", "TreatmentC"] // Answer in collection of values
Text Operations
"Hello" + " " + "World" // Combine text → "Hello World"
demographics.firstName + " " + demographics.lastName // Full name
Making Decisions (IF-THEN-ELSE)
// IF age >= 18 THEN "Adult" ELSE "Minor"
age >= 18 ? "Adult" : "Minor"
// Nested decisions
temperature > 100 ? "Fever" :
temperature > 98.6 ? "Elevated" : "Normal"
Accessing Your Data
The MyDataHelps implementation of CEL offers access to information about the current participant, including current and past survey answers.
Survey Answers
// Simple answer access
weight // Current weight answer
painLevel // Current pain level
// Explicit access
surveyAnswers.getCurrent("painLevel") // Current pain level
sa.c("weight", "Week 1") // weight for "Week 1" event
// All answers, current + historical
surveyAnswers.getAll("weight") // All weight measurements
sa.a("weight") // All weight measurements, shorthand notation
Participant Information
customFields.TreatmentGroup
participantIdentifier
demographics.firstName
enrollmentDate
institution.code
project.name
organization.description
Helpful Functions
CEL supports a wide range of functions, allowing complex calculations and operations.
Aggregation
sum(sa.a("score")) // Add values together
mean(sa.a("score")) // Calculate average
min(sa.a("score")) // Find lowest value
max(sa.a("score")) // Find highest value
Date Functions
today() // Today's date
dateDiff(today(), birthdate, "years") // Age calculation
addDays(today(), 7) // One week from now
formatDate(visitDate, "MMM d, yyyy") // Format: Jan 22, 2026
Text Functions
response.answers[0].contains("yes") // Check if text contains word
email.answers[0].matches(".*@.*") // Validate format
name.answers[0].size() // Count characters
List Functions
// Filter pain scores > 5
sa.a("pain").filter(x, x > 5)
// Check if any match
sa.a("symptoms").exists(x, "Fever" in x)
Basic Syntax
The basic building blocks of CEL are types, type conversion, and operators.
Supported Types
| Type | DESCRIPTION | Examples |
|---|---|---|
| Integer | Whole numbers |
42 -17 |
| Decimal | Floating-point numbers |
3.14 -0.5 |
| String | Text, enclosed in double quotes |
"Hello, World!" "Control" |
| Boolean | A true or false value |
true false |
| Date | Text representation of a date, in ISO format (YYYY-MM-DD) | "2026-01-22" |
| Timestamp | Text representation of a date, in ISO-8601 format including timezone offset. | "2026-01-22T10:30:00-05:00" |
|
TimeOfDay |
Text representation of a time, in 12-hour time format |
"10:30 AM" "02:45 PM" |
| List | List (array) of variables of another type, enclosed in square brackets. |
[1, 2, 3, 4, 5] ["apple", "banana", "cherry"] [true, false, false] |
| Null | Absence of a value | null |
Type Conversion
CEL provides built-in functions to convert between these types.
String Conversion
Convert any value to a string:
string(123) // → "123"
string(3.14) // → "3.14"
string(true) // → "true"
Integer Conversion
Convert to an integer (hole number):
int("42") // → 42
int(3.14) // → 3 (truncates decimal)
int("3.14") // → 3
Decimal Conversion
Convert to a decimal (floating-point number):
double("3.14") // → 3.14
double(42) // → 42.0
double("42") // → 42.0
Operators
Arithmetic Operators
| Operator | Description | Example | Result |
|---|---|---|---|
+ |
Addition |
`5 + 3` | 8 |
- |
Subtraction | `10 - 4` | 6 |
* |
Multiplication | `6 * 7` | 42 |
/ |
Division | `15 / 3` | 5 |
% |
Modulo (remainder) | `17 % 3` | 2 |
- (unary) |
Negation | `-5` | -5 |
Examples:
(8 + 2) * 3 // → 30
age * 2 + 10 // If age is 25: → 60
Comparison Operators
| Operator | Description | Example | Result |
|---|---|---|---|
== |
Equal to | 5 == 5 |
true |
!= |
Not equal to | 5 != 3 |
true |
< |
Less than | 3 < 5 |
true |
<= |
Less than or equal to | 5 <= 5 |
true |
> |
Greater than | 7 > 5 |
true |
>= |
Greater than or equal to | 5 >= 3 |
true |
Examples:
age >= 18 // Check if adult
gender == "F" // Check gender
Logical Operators
| Operator | Description | Example | Result |
|---|---|---|---|
&& |
Logical AND | true && false |
false |
|| |
Logical OR | true || false |
true |
! |
Logical NOT | !true |
false |
Examples:
age >= 18 && age < 65 // Working age adult
status == "active" || status == "pending" // Either status
!hasCompletedSurvey // Survey not completed
String Concatenation
The + operator concatenates strings:
"Hello" + " " + "World" // → "Hello World"
"Welcome " + firstName // → "Welcome John"
Membership Operator
| Operator | Description | Example | Result |
|---|---|---|---|
in |
Membership test | "a" in ["a", "b"] |
true |
Examples:
"apple" in ["apple", "banana"] // → true
"Headache" in symptoms // Check if symptom selected
status in ["active", "pending"] // Status is one of these
Ternary Operator (Conditional)
Returns one of two values based on a condition:
condition ? valueIfTrue : valueIfFalse
Examples:
age >= 18 ? "adult" : "minor"
score >= 90 ? "A" : score >= 80 ? "B" : "C"
enrolled && active ? "Participating" : "Not Participating"
Operator Precedence
Operators are evaluated in the following order (highest to lowest precedence):
- Unary operators:
!,-(negation) - Multiplicative:
*,/,% - Additive:
+,- - Comparison:
<,<=,>,>= - Equality:
==,!= - Logical AND:
&& - Logical OR:
|| - Ternary:
? :
Use parentheses to override precedence:
(age + 5) * 2 // Add first, then multiply
age >= 18 && (status == "active" || status == "pending") // If age = 18 AND status is "active" or "pending"
Functions
The MyDataHelps CEL implementation supports built-in CEL functions as well as custom MyDataHelps functions. All supported functions are defined below.
String Functions
String functions work with string literals and string variables.
| function | DESCRIPTION | Example |
|---|---|---|
contains(substring) |
Check if string contains substring | "Hello World".contains("World") → true |
startsWith(prefix) |
Check if string starts with prefix | "Hello".startsWith("He") → true |
endsWith(suffix) |
Check if string ends with suffix | "file.txt".endsWith(".txt") → true |
matches(pattern) |
Check if string matches regex | customFields.MyAnswer.matches(".*@.*\\..*") → true |
size() |
Get string length | "Hello".size() → 5 |
Examples with survey answers:
comments.contains("yes") // Check if comments contains "yes"
email.matches("[a-zA-Z0-9]+@[a-zA-Z0-9]+\\.[a-zA-Z]+") // Email validation
name.size() > 3 // Name length check
List Functions
List functions operate on lists of objects.
| function | DESCRIPTION | Example |
|---|---|---|
size() |
Get number of elements | [1, 2, 3].size() → 3 |
map(var, expr) |
Transform each element | [1, 2, 3].map(x, x * 2) → [2, 4, 6] |
filter(var, cond) |
Keep matching elements | [1, 2, 3, 4].filter(x, x > 2) → [3, 4] |
exists(var, cond) |
Check if any match | [1, 3, 5].exists(x, x > 4) → true |
all(var, cond) |
Check if all match | [1, 2, 3].all(x, x > 0) → true |
has(field) |
Check if key exists | has(customFields.Age) → true/false |
Examples with survey answers:
// Size
sa.a("weight").size() // Count of weight measurements
// Filter
sa.a("pain").filter(x, x > 5) // Pain scores > 5
// Exists
sa.a("symptoms").exists(x, "Headache" in x) // Any headache across any symptom answer
// All
sa.a("compliance").all(x, x == "Yes") // 100% compliant?
// Has
has(my_answer) ? my_answer : 0 // Default value if my_answer has no or invalid current value
Math Functions
Aggregate Functions
| function | DESCRIPTION | Example |
|---|---|---|
sum(list) |
Sum all numbers | sum([1, 2, 3]) → 6 |
mean(list) |
Calculate average | mean([10, 20, 30]) → 20 |
min(list) |
Find minimum | min([5, 2, 8]) → 2 |
max(list) |
Find maximum | max([5, 2, 8]) → 8 |
Examples with survey answers:
mean(sa.a("weight")) // Average of all weight answers
sum(sa.a("score")) // Add up all score values
min([score1, score2, score3]) // Explicitly min of 3 scores by result identifier
Rounding Functions
| function | DESCRIPTION | Example |
|---|---|---|
round(number) |
Round to nearest integer | round(3.6) → 4 |
round(number, places) |
Round to decimal places | round(3.14159, 2) → 3.14 |
abs(number) |
Absolute value | abs(-5) → 5 |
ceil(number) |
Round up to integer | ceil(3.1) → 4 |
ceil(number, places) |
Round up to decimal places | ceil(3.141, 2) → 3.15 |
floor(number) |
Round down to integer | floor(3.9) → 3 |
floor(number, places) |
Round down to decimal places | floor(3.149, 2) → 3.14 |
Examples:
// Basic rounding
round(bmi) // Round BMI to integer
abs(weight - targetWeight) // Weight difference (absolute)
// Decimal places
round(3.14159, 2) // → 3.14 (round to 2 decimals)
round(bmi, 1) // → 24.7 (round BMI to 1 decimal)
ceil(3.141, 2) // → 3.15 (round up to 2 decimals)
floor(3.149, 2) // → 3.14 (round down to 2 decimals)
// Negative decimal places (round to tens, hundreds, etc)
round(1234.5, -1) // → 1230 (round to nearest 10)
round(1567, -2) // → 1600 (round to nearest 100)
ceil(1234, -2) // → 1300 (round up to nearest 100)
floor(1267, -2) // → 1200 (round down to nearest 100)
// Practical uses
round(dosage / 10, 1) // Dosage calculation with 1 decimal
ceil(age / 10) * 10 // Age decade (rounded up)
floor(price, 2) // Price rounded down to cents
Date & Time Functions
Date functions work with multiple formats and preserve input formatting where possible.
Supported formats:
- Date only:
"2026-01-22"(YYYY-MM-DD) - ISO timestamp:
"2026-01-22T10:30:00Z"(UTC) - Timestamp with timezone:
"2026-01-22T10:30:00-05:00" - Time only:
"10:00 AM"or"2:30 PM"(12-hour format with AM/PM)
Current Date/Time:
| function | returns | Example |
|---|---|---|
today() |
Current date (YYYY-MM-DD) | today() → "2026-01-22" |
now() |
Current timestamp with timezone | now() → "2026-01-22T14:30:00-05:00" |
Date Arithmetic:
| function | description | Example |
|---|---|---|
addDays(date, days) |
Add/subtract days | addDays("2026-01-22", 7) → "2026-01-29" |
addMonths(date, months) |
Add/subtract months | addMonths(today(), 3) → 3 months from now |
addHours(datetime, hours) |
Add/subtract hours | addHours("10:00 AM", 2) → "12:00 PM" |
addMinutes(datetime, minutes) |
Add/subtract minutes |
addMinutes("2026-01-22T10:00:00-05:00", 45) → "2026-01-22T10:45:00-05:00" |
Date Components:
| function | description | Example |
|---|---|---|
year(date) |
Extract year | year("2026-01-22") → 2026 |
month(date) |
Extract month (1-12) | month("2026-01-22") → 1 |
day(date) |
Extract day (1-31) | day("2026-01-22") → 22 |
Date Formatting & Validation:
| function | description | Example |
|---|---|---|
formatDate(date, pattern) |
Format date with pattern | formatDate("2026-01-22", "MMM d, yyyy") → "Jan 22, 2026" |
isDate(value) |
Check if valid date | isDate("2026-01-22") → true |
dateDiff(date1, date2, unit) |
Difference between dates | dateDiff(today(), birthdate, "years") → age |
Common format patterns: MM/dd/yyyy (default), MMMM d, yyyy, yyyy-MM-dd
dateDiff units: "days" (default), "months", "years", "hours", "minutes"
Format Preservation Examples:
// Date only (YYYY-MM-DD) → always returns date only
addDays("2026-01-22", 7) // → "2026-01-29"
addMonths("2026-01-22", 3) // → "2026-04-22"
// ISO timestamp (UTC) → returns ISO timestamp
addDays("2026-01-22T10:30:00Z", 7) // → "2026-01-29T10:30:00Z"
addHours("2026-01-22T10:30:00Z", 2) // → "2026-01-22T12:30:00Z"
// Timestamp with timezone → preserves timezone
addDays("2026-01-22T10:30:00-05:00", 7) // → "2026-01-29T10:30:00-05:00"
addHours("2026-01-22T10:30:00-05:00", 2) // → "2026-01-22T12:30:00-05:00"
// Time only (12-hour AM/PM) → returns time only
addHours("10:00 AM", 2) // → "12:00 PM"
addMinutes("10:00 AM", 45) // → "10:45 AM"
addMinutes("11:30 PM", 45) // → "12:15 AM" (wraps to next day)
// Time calculations (time-only format)
dateDiff("2:00 PM", "10:00 AM", "hours") // → 4
dateDiff("2:30 PM", "2:00 PM", "minutes") // → 30
// Age calculation
dateDiff(today(), birthdate, "years") // Age in years
// Date components work with all formats
year("2026-01-22") // → 2026
year("2026-01-22T10:30:00Z") // → 2026
month("2026-01-22") // → 1 (January)
day("2026-01-22") // → 22
// Formatting (always outputs as specified pattern)
formatDate("2026-01-22", "MMMM d, yyyy") // → "January 22, 2026"
formatDate(appointmentDate, "EEEE, MMM d") // → "Wednesday, Jan 22"
Random Functions
Seeded Random (Deterministic)
Seeded randomization functions produce the same result for the same seed, and should be used wherever reproducibility is required such as participant assignment.
| function | description | Example |
|---|---|---|
random.int(min, max, seed) |
Random integer in range | random.int(1, 100, participantID) → same value per participant |
random.pick(list, seed) |
Pick random element | random.pick(["A", "B", "C"], participantID) → same choice per participant |
random.float(seed) |
Random 0-1 decimal | random.float(participantID) → same float per participant |
Examples:
// Random assignment (reproducible)
random.int(0, 1, participantID) == 0 ? "Group A" : "Group B"
random.pick(["Control", "Treatment A", "Treatment B"], participantID)
// Weighted randomization
random.float(participantID) < 0.7 ? "Common" : "Rare" // 70/30 split
Non-Seeded Random (Ephemeral)
Non-seeded randomization functions can produce a different value with each valuation.
Using non-seeded randomization produces a different value each time and is not reproducible, which may make it unsuitable for participant assignment.
| function | description | Example |
|---|---|---|
random.nowInt(min, max) |
Random integer (changes each time) | random.nowInt(1, 6) → dice roll |
random.nowPick(list) |
Pick random element (changes) | random.nowPick(["A", "B"]) → different each time |
random.nowFloat() |
Random 0-1 decimal (changes) | random.nowFloat() → different each time |
Examples:
// Random messages (OK for ephemeral use)
random.nowPick(["Great job!", "Keep it up!", "Well done!"])
// NOT for assignment
random.nowInt(0, 1) // ❌ DON'T use for group assignment, unless you don't care about reproducibility
Survey Answers
A key feature of the MyDataHelps Common Expression Language is the ability to easily access survey answers. Any survey answer can easily be accessed by its resultidentifier, even if it's not in the current survey.
For instance:
// Access survey answer with result identifier "weight"
weight
// Use in expression
weight > 150
Accessing Survey Answers
Survey answers can be accessed in multiple ways depending on what is needed for this expression.
Current Survey Answers
Method 1: Direct Variable Access (Recommended)
If the result identifier is a valid variable name, you can access it directly:
// Simple access
painLevel
// In calculations
bmi >= 25
// In comparisons
bloodPressure == "120/80"
Method 2: surveyAnswers.getCurrent() and sa.c() shorthand
Use surveyAnswers.getCurrent() (or the sa.c() shorthand) when you need to get the answer for a specific event or when the result identifier isn't a valid variable name.
// Access current answer for a result identifier
surveyAnswers.getCurrent("resultIdentifier")
// Shorthand - same as above
sa.c("resultIdentifier")
// Access current answer for a specific event
sa.c("resultIdentifier", "eventName")
Examples:
// When identifier has special characters
sa.c("step-1-pain") // Result identifier with hyphen
// When identifier starts with number
sa.c("123field") // Starts with number
// Specify event explicitly
sa.c("weight", "Week 1") // Weight from Week 1 event
// Access step identifier
sa.c("weight", "Week 1") // Step identifier for weight from Week 1 event
Current Answer Resolution Order
When accessing "current" answers, MyDataHelps follows this resolution order:
-
Check Current Survey Context
- If result identifier is in the current survey
- AND (no event specified OR event matches current event)
- THEN return answer from current survey context
-
Check Current Event
- If the survey is being run under an event AND no event was specified
- THEN take the current answer (by instance) for the current event
-
Return First Match
- Take the current answer (by instance) for the latest event
Example Scenario:
A participant is completing a "Week 2" event survey with the answer `weight` = 155. Historical survey data exists for "Week 1" event with `weight` = 150 and "Week 3" with `weight` = 160
// Editing Week 2 survey:
weight // Returns: 155 (current survey)
sa.c("weight") // Returns: 155 (current survey)
sa.c("weight", "Week 1") // Returns: 150 (historical)
sa.c("weight", "Week 3") // Returns: 160 (future historical)
// After Week 2 survey submitted, no survey context:
weight // Returns: 160 (most recent = Week 3)
sa.c("weight") // Returns: 160
sa.c("weight", "Week 2") // Returns: 155 (week 2)
All Answers Access
You may also access all past answers for a given question, not just the current one. Use surveyAnswers.getAll() (or the sa.a() shorthand) to access a list of survey answers.
// Get all answers for a result identifier
surveyAnswers.getAll("resultIdentifier")
// Get all answers for a result identifier
sa.a("resultIdentifier")
// Get all answers for a specific event
sa.a("resultIdentifier", "eventName")
This method returns an array of answer values. In most cases this is just a single value (e.g. an integer, decimal, string) depending on the type of question. In the case of multiple-choice questions or calculated fields that return lists, it may be an array of arrays.
Examples:
// Get all weight measurements
sa.a("weight") // Returns: all weight answer values
// Get all weight measurements from Week 1
sa.a("weight", "Week 1") // Returns: Week 1 weight answer values
// Use with list functions
sa.a("weight").size() // Count of weight measurements
// Returned values are primitives
sa.a("pain") // Returns an array of pain answer values
// Multiple choice values return an array of arrays
sa.a("symptoms").filter(x, "Headache" in x) // Filters answers to symptoms question to only those that contian "Headache"
Survey Answer Types
The answer property on a SurveyAnswer object, and any answers using direct variable access are automatically typed based on their question's answer format.
Those answers that are unable to be converted to their type will be null if referenced in direct variable access. However, surveyAnswers.getCurrent() and surveyAnswers.getAll() will contain a SurveyAnswer object (with a null answer). This may be used for detecting blank or null answers. Note that answers that are skipped by branching logic are not stored at all.
The type conversion happens when data is retrieved, and is based on the currently published version of the survey in your project.
| Answerformat | resulting type | notes |
|---|---|---|
| Text | string | |
| Numeric | double | must parse to an integer or double |
| Text Choice | double, string, list | If multiple choice, will be list. If all choices are numeric, value(s) will be double, otherwise they will all strings |
| Yes/No Choice | boolean | must parse to case-insensitive string "true" or "false", or be the number 1 or 0 |
| Scale (Discrete) | double | |
| Scale (Continuous) | double | |
| Date | string | if "Date Only", is in ISO Date format, e.g. "2026-01-22". If "Date and Time", is full ISO timestamp with timezone offset, e.g. "2026-01-22T10:30:00-05:00" |
| Time of Day | string | in 12-hour format with AM/PM, e.g. "10:20 AM" |
| Value Picker | double or string | If all choices are numeric, value will be double, otherwise they will all strings |
| File Upload | string | |
| Height | double | |
| Weight | double | |
| Phone Number | string | |
| Postal Code | string | |
| Date of Birth | in ISO Date format, e.g. "2026-01-22" | |
| Email Address | string |
Web View Steps always return strings.
Calculated fields will be converted the type of the field (e.g. if declared as a list of decimals).
Variables
CEL supports the following variables.
| Variable | Example | Description |
|---|---|---|
| participantID | `participantID` | The ID of the participant (internal to MyDataHelps, automatically assigned) |
| participantIdentifier | `participantIdentifier` | The participant identifier (can be viewed from the Participants tab) |
| enrollmentDate | `enrollmentDate` | The enrollment date of the participant |
| customFields | `customFields.MyCustomField` | Any custom field in your project — just change “MyCustomField” to your custom field name. For instance: customFields.SurgeryDate, customFields.Arm |
| demographics | `demographics.firstName` |
The demographic fields supported are:
|
|
institution |
`institution.code` or `institution.name` |
The Code or Name of the participant's assigned Institution. |
| project | `project.description` |
Information about the project. Supported fields are:
|
| organization | `organization.name` |
Information about the workspace. Supported fields are:
|
| surveyAnswers |
|
Access to survey responses. Supports the following methods:
|
Common Examples
// Participant information
demographics.firstName + " " + demographics.lastName
dateDiff(today(), enrollmentDate, "days") // Days enrolled
// Custom fields
customFields.age >= 18
customFields.studyGroup == "Treatment"
// Institution and project
institution.code == "01"
project.name + " - " + organization.name
// Survey answers
weight > 150
sa.c("pain", "Week 1") > 5
mean(sa.a("scores").map(x, int(x)))