Skip to main content
RapidDev - Software Development Agency
retool-tutorial

How to Create Custom Validators in Retool Forms

Custom validators in Retool are JavaScript expressions entered in the Inspector's Custom Validation field. The expression must return an empty string (valid) or an error message string (invalid). Reference other components with {{ }} syntax. For async checks, the expression evaluates against already-loaded query data — it does not itself trigger queries.

What you'll learn

  • Write a Custom Validation expression on any input component that returns an error string or empty string
  • Implement cross-field validation referencing sibling component values like {{ passwordInput.value }}
  • Perform async uniqueness checks against query data using a Custom Validation expression
  • Chain custom validation with built-in rules so both sets must pass for form1.invalid to clear
  • Debug custom validators using the State Inspector to trace expression evaluation
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate9 min read20-25 minRetool Cloud and Self-hostedLast updated March 2026RapidDev Engineering Team
TL;DR

Custom validators in Retool are JavaScript expressions entered in the Inspector's Custom Validation field. The expression must return an empty string (valid) or an error message string (invalid). Reference other components with {{ }} syntax. For async checks, the expression evaluates against already-loaded query data — it does not itself trigger queries.

Quick facts about this guide
FactValue
ToolRetool
DifficultyIntermediate
Time required20-25 min
CompatibilityRetool Cloud and Self-hosted
Last updatedMarch 2026

Writing JavaScript Custom Validation Expressions in Retool

Retool's built-in validation rules (Required, Email, Min/Max Length) cover standard cases, but production forms often need rules the Inspector cannot express: confirm-password matching, business logic constraints, or uniqueness checks against your database. Custom Validation fields accept JavaScript expressions that integrate directly into the form's validation lifecycle.

A Custom Validation expression runs every time the component's value changes. It receives the component's current value as self and can reference any other component's value via {{ }}. If the expression returns an empty string, the field is valid. If it returns a non-empty string, that string becomes the inline error message and the component's .invalid property becomes true.

This tutorial builds three progressive examples: a password-confirm match validator, a cross-field numeric constraint, and an async-style uniqueness check that evaluates against pre-loaded query data. Note that Custom Validation expressions cannot themselves trigger queries — they evaluate synchronously against already-available data.

Prerequisites

  • A Retool Form component with at least two input components for cross-field examples
  • Familiarity with built-in validation rules from the how-to-use-validation-in-retool-forms tutorial
  • Basic JavaScript knowledge (ternary operators, string comparisons)
  • Optional: a query that loads existing records for uniqueness checking

Step-by-step guide

1

Locate the Custom Validation field in the Inspector

Select a Text Input component inside your form. In the Inspector, scroll to the Validation section. Below the built-in rule toggles (Required, Min Length, Max Length, Pattern), you will find a Custom Validation field. Click the {{ fx }} icon to switch it to JavaScript expression mode. This field evaluates a JavaScript expression and expects the return value to be either an empty string '' (valid) or an error string (invalid). The error string is displayed as inline red text below the field.

Expected result: The Custom Validation field accepts a JavaScript expression and shows a preview of the current return value in the Inspector.

2

Write a confirm-password match validator

Add two Text Input components inside the form: passwordInput (type: Password) and confirmPasswordInput (type: Password). On confirmPasswordInput, open the Validation section and enter this Custom Validation expression. The expression compares confirmPasswordInput's current value (self) against passwordInput.value and returns an error message if they differ. The self keyword refers to the current component's value in Custom Validation expressions.

typescript
1// Custom Validation on confirmPasswordInput:
2{{ self !== passwordInput.value ? 'Passwords do not match' : '' }}
3
4// With additional length check (combining with built-in Min Length):
5{{ self !== passwordInput.value ? 'Passwords do not match' : '' }}
6// Min Length: 8 (set as built-in rule — both rules must pass)

Expected result: Typing a non-matching value in confirmPasswordInput shows 'Passwords do not match'. Matching values clear the error.

3

Build a cross-field numeric constraint validator

For a date range or budget allocation form, you may need to ensure one number is less than another. Add minValueInput and maxValueInput (Number Input components). On maxValueInput, add a Custom Validation expression that checks the relationship. This pattern works for any two-field comparison: start/end dates (using Date Pickers), minimum/maximum quantities, etc.

typescript
1// Custom Validation on maxValueInput:
2{{ self <= minValueInput.value ? 'Maximum must be greater than minimum' : '' }}
3
4// For date range validation on endDatePicker:
5{{ self && startDatePicker.value && new Date(self) <= new Date(startDatePicker.value)
6 ? 'End date must be after start date'
7 : '' }}
8
9// Budget allocation: sum of parts must not exceed total
10{{ (budgetQ1.value + budgetQ2.value + budgetQ3.value + budgetQ4.value) > totalBudgetInput.value
11 ? `Total allocation ${budgetQ1.value + budgetQ2.value + budgetQ3.value + budgetQ4.value} exceeds budget ${totalBudgetInput.value}`
12 : '' }}

Expected result: Setting maxValueInput to a value less than or equal to minValueInput shows the error message and sets form1.invalid = true.

4

Implement a uniqueness check against query data

For email or username uniqueness, load existing records with a query that runs on page load, then reference that query's data in the Custom Validation expression. Because Custom Validation expressions cannot trigger queries themselves, the uniqueness data must already be loaded. Create a query named getExistingEmails that returns a list of email records. On the emailInput, add a Custom Validation expression that checks whether the current value appears in that list.

typescript
1-- SQL query: getExistingEmails (Run on page load: ON)
2SELECT LOWER(email) as email FROM users;
3
4// Custom Validation on emailInput:
5{{
6 getExistingEmails.data.some(row => row.email === self.toLowerCase())
7 ? 'This email address is already registered'
8 : ''
9}}
10
11// For case-insensitive username check:
12{{
13 getExistingUsernames.data.some(row => row.username.toLowerCase() === self.toLowerCase())
14 ? 'Username already taken'
15 : ''
16}}

Expected result: Entering an email that exists in the database shows 'This email address is already registered'. A new email clears the error.

5

Combine custom validation with built-in rules

Custom Validation stacks on top of built-in rules — a field is only valid when all built-in rules AND the custom expression pass. For emailInput, you can have Required = true, Pattern = Email (built-in), and also the uniqueness custom validation. The field will show the most recently triggered error. Set the order of severity: the built-in Email pattern validation will prevent the uniqueness check from running on malformed input since Retool short-circuits on the first failing rule.

Expected result: An empty email triggers the Required error. A malformed email triggers the Email pattern error. A valid but taken email triggers the uniqueness error.

6

Use the State Inspector to debug custom validation expressions

If a Custom Validation expression is not behaving as expected, open the Debug Panel at the bottom of the editor and click State Inspector. Find the component (e.g., emailInput) and expand its properties. You will see the current .value and .invalid state. You can also add a Text component to the canvas temporarily and set its Value to the raw expression to see what it returns: {{ getExistingEmails.data.some(row => row.email === emailInput.value.toLowerCase()) }}. This isolates whether the issue is the query data, the comparison logic, or the component reference.

typescript
1// Temporary debug Text component value:
2// Paste this to see the raw expression result
3{{ JSON.stringify(getExistingEmails.data.slice(0, 5)) }}
4
5// Check if the comparison logic is correct:
6{{ getExistingEmails.data.some(row => row.email === emailInput.value.toLowerCase()) }}

Expected result: State Inspector shows emailInput.invalid = true when the expression returns a non-empty string.

7

Prevent form submission until custom validators pass

Custom validation integrates with form1.invalid automatically. Any component with a failing Custom Validation expression contributes to form1.invalid = true. Ensure the Submit button's Disabled property is set to {{ form1.invalid }} and optionally call form1.validate() at the start of the submit JS Query for defense-in-depth. The validate() call forces all error messages to display even if not yet touched.

typescript
1// JS Query: submitWithCustomValidation
2const isValid = form1.validate();
3
4if (!isValid) {
5 utils.showNotification({
6 title: 'Please fix the errors',
7 description: 'Check all highlighted fields before submitting.',
8 notificationType: 'warning',
9 });
10 return;
11}
12
13// Safe to submit — all built-in and custom validations passed
14await createUser.trigger({
15 additionalScope: {
16 email: emailInput.value,
17 username: usernameInput.value,
18 password: passwordInput.value,
19 }
20});
21
22form1.reset();

Expected result: The form will not submit while any custom validator returns a non-empty error string.

Complete working example

Custom Validation expressions — reference sheet
1// === CONFIRM PASSWORD ===
2// On confirmPasswordInput → Custom Validation:
3// self !== passwordInput.value ? 'Passwords do not match' : ''
4
5// === CROSS-FIELD NUMERIC ===
6// On maxValueInput → Custom Validation:
7// self <= minValueInput.value ? 'Max must be greater than min' : ''
8
9// === DATE RANGE ===
10// On endDatePicker → Custom Validation:
11// self && startDatePicker.value && new Date(self) <= new Date(startDatePicker.value)
12// ? 'End date must be after start date'
13// : ''
14
15// === EMAIL UNIQUENESS ===
16// On emailInput → Custom Validation:
17// getExistingEmails.data.some(row => row.email === self.toLowerCase())
18// ? 'Email already registered'
19// : ''
20
21// === USERNAME FORMAT + UNIQUENESS COMBINED ===
22// On usernameInput → Custom Validation:
23// (() => {
24// if (!/^[a-z0-9_]{3,20}$/.test(self)) {
25// return 'Username must be 3-20 lowercase letters, numbers, or underscores';
26// }
27// if (getExistingUsernames.data.some(r => r.username === self)) {
28// return 'Username already taken';
29// }
30// return '';
31// })()
32
33// IIFE pattern for multi-condition validators:
34(()=> {
35 if (!self) return '';
36 if (!/^[a-z0-9_]{3,20}$/.test(self)) {
37 return 'Username must be 3-20 lowercase letters, numbers, or underscores';
38 }
39 if (getExistingUsernames.data.some(r => r.username.toLowerCase() === self.toLowerCase())) {
40 return 'Username already taken';
41 }
42 return '';
43})()

Common mistakes when creating Custom Validators in Retool Forms

Why it's a problem: Returning true/false from the Custom Validation expression instead of a string — false does not register as invalid in Retool's system

How to avoid: Always return an error string (non-empty) for invalid and an empty string '' for valid. Example: {{ self !== confirmPasswordInput.value ? 'Passwords do not match' : '' }}

Why it's a problem: Trying to call await query.trigger() inside a Custom Validation expression — Custom Validation is a synchronous expression and cannot await async operations

How to avoid: Pre-load the validation data with a query that runs on page load and reference query.data in the Custom Validation expression. For debounced checks, use a Change event handler on the input to trigger the query, then read its data reactively.

Why it's a problem: Transformers are used to compute the list of existing values for uniqueness checks and the developer tries to trigger a query update from within the transformer

How to avoid: Transformers are read-only — they cannot trigger queries or set state. Use the query directly and reference query.data in the validation expression, or compute the derived list in a standalone transformer that reads from query.data.

Why it's a problem: Custom validator references passwordInput.value but the component is named passwordField1 — the reference silently returns undefined causing the validator to always pass

How to avoid: Double-check component names in the Inspector (the Name field at the top of the Inspector panel, not the Label). Use the State Inspector to verify the component is accessible by the name you expect.

Best practices

  • Return an empty string '' for valid state and a human-readable error string for invalid — never return a boolean, null, or undefined
  • Use IIFE (immediately invoked function expression) syntax for multi-condition validators: (()=>{ ... })() to keep logic readable
  • Load uniqueness-check data with 'Run on page load' on the query — do not attempt to trigger queries from within a Custom Validation expression
  • For large datasets where loading all records is impractical, use a debounced Change event handler to run a count query, then reference the count result in the Custom Validation expression
  • Stack built-in rules and custom validators together — built-in rules are evaluated first and provide efficient early failures before the custom expression runs
  • Use self to reference the current component value inside Custom Validation expressions instead of the component name — it is more readable and less error-prone
  • Test edge cases: empty string, null, leading/trailing whitespace, and case differences — normalize with .trim() and .toLowerCase() defensively

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I am building a Retool registration form with these fields: emailInput (Text Input), usernameInput (Text Input), passwordInput (Password Text Input), confirmPasswordInput (Password Text Input). I have a query named getExistingUsers that returns an array of objects with email and username fields. Write the Custom Validation expressions for: (1) confirmPasswordInput to match passwordInput, (2) emailInput to check uniqueness against getExistingUsers.data, (3) usernameInput to enforce lowercase letters/numbers/underscores only (3-20 chars) AND check uniqueness. Include IIFE syntax for the multi-condition validator.

Retool Prompt

In my Retool form, I need to validate that endDatePicker is after startDatePicker. What do I write in endDatePicker's Custom Validation field? Also explain whether I should use the built-in Date validation or a Custom Validation expression for this cross-field check.

Frequently asked questions

Can I use async/await inside a Custom Validation expression?

No. Custom Validation expressions are synchronous. They cannot contain await, Promise resolution, or query triggers. To validate against live database data, pre-load the data with a query (Run on page load) and reference query.data in the expression, or use a debounced Change handler to trigger a query and read its result.

What happens when both a built-in rule and a Custom Validation expression fail at the same time?

Retool displays the error from the first failing rule. Built-in rules are evaluated before the Custom Validation expression. If Required fails, the Required error message shows. Once the user fixes that and triggers re-evaluation, the Custom Validation error becomes visible. This is why stacking rules is useful — it guides the user through errors progressively.

How do I access the value of a component inside a different container in a Custom Validation expression?

Use the component's name directly in the expression regardless of container nesting — Retool exposes all named components in the app's global scope. For example, {{ self !== passwordInput.value ? 'Mismatch' : '' }} works even if confirmPasswordInput and passwordInput are in different nested containers, as long as both names are unique in the app.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Learning is great. Shipping is faster with help.

Our engineers have built 600+ apps on Retool and the tools around it. If your project needs to be live sooner than your learning curve allows — book a free consultation.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.