
This is a Servoy Tutorial on the modern JavaScript features that have quietly arrived in the platform over the last few releases. If you have been writing Servoy code the same way you learned it in 2014, the language has grown up around you, and the platform has finally caught up. Since Servoy 2025.09, the modern ECMAScript parser is the default. Since 2026.03, the old parser preference is gone entirely. You can now write const, arrow functions, template literals, destructuring, optional chaining, and default parameters, and the editor knows what you mean.
I am going to walk you through what is actually available, when each feature makes your code better, and when the old patterns are still the right answer. Fair warning: the answer is not “use all the new stuff everywhere.” Some of it is genuinely useful in Servoy. Some of it fights against how the platform works. Knowing which is which is the point of this tutorial.
A Brief History of How We Got Here
Servoy ran on Rhino 1.7 for a long time. Rhino 1.7 spoke a dialect of JavaScript that was mostly ES5 with a few extensions bolted on. That is what you learned. That is what the coding conventions in most Servoy shops were built around.
Then a few things happened in quick succession:
- Servoy 2025.06 upgraded to Rhino 1.8.0, which brought real support for modern JavaScript language features. The old parser was still the default.
- Servoy 2025.09 flipped the switch and made the modern ECMAScript parser default-on for new solutions. You could still opt back to the old parser in preferences if you had a lot of legacy code.
- Servoy 2026.03 removed the old parser preference entirely. There is no going back. The modern parser is the parser.
If you maintain an LTS branch (2024.03 LTS or 2025.03 LTS), you are still on the old parser behavior and need to stick to classic ES5 patterns. Everything in this article targets 2025.09 or later. Check your target version before you start rewriting code with template literals, or your LTS deployment will refuse to load it.
Make sense? Let’s look at what you actually get.
let and const: Block Scoping Finally Works
In classic ES5 Servoy code, you declared variables with var at the top of the function. That pattern existed because var is function-scoped and hoists to the top of its containing function, so putting declarations at the top matched what the engine was going to do anyway.
let and const are block-scoped. That means a let declared inside an if block does not exist outside that block. A let declared inside a for loop is scoped to the loop body and is fresh on every iteration. This matches how almost every other modern language works.
/** * Processes active customers. Classic ES5 style. * @author Gary Dotzlaw * @since 2026-04-12 * @public * * @param {JSFoundSet<db:/myserver/crm_customer>} fsCustomers */function processCustomersClassic(fsCustomers) { /**@type {Number}*/ var iActiveCount = 0; /**@type {String}*/ var sReport = '';
for (var i = 1; i <= fsCustomers.getSize(); i++) { /**@type {JSRecord<db:/myserver/crm_customer>}*/ var rCustomer = fsCustomers.getRecord(i); if (rCustomer.cust_active === 1) { iActiveCount++; sReport += rCustomer.company_name + '\n'; } }
application.output('Active: ' + iActiveCount);}Here is the same function written with block-scoped declarations:
/** * Processes active customers. Modern style. * @author Gary Dotzlaw * @since 2026-04-12 * @public * * @param {JSFoundSet<db:/myserver/crm_customer>} fsCustomers */function processCustomersModern(fsCustomers) { /**@type {Number}*/ let iActiveCount = 0; /**@type {Array<String>}*/ const aLines = [];
for (let i = 1; i <= fsCustomers.getSize(); i++) { /**@type {JSRecord<db:/myserver/crm_customer>}*/ const rCustomer = fsCustomers.getRecord(i); if (rCustomer.cust_active === 1) { iActiveCount++; aLines.push(rCustomer.company_name); } }
application.output('Active: ' + iActiveCount);}A few things changed:
constfor references that do not reassign.aLinesis a const. The array itself is not frozen, but the variable name never rebinds to a different array. This is the default for most local variables in modern code.letfor references that do reassign.iActiveCountis a counter, so it useslet.const rCustomerinside the loop. Each iteration gets a freshrCustomerbinding, scoped to that single iteration of the loop body. That is the right mental model. Avar rCustomerleaks out to the entire function and gets reused, which is confusing and occasionally dangerous.aLines.push(...)instead of string concatenation. The array-then-join pattern is faster and more idiomatic than repeatedly concatenating strings.
The 2025.12 Breaking Change You Should Know About
Before 2025.12, block scoping for const and let in Servoy was inconsistent. In specific conditions they behaved as block-scoped, in others they leaked out into the enclosing function. The 2025.12 release fixed this. Quoting the release notes directly: “the const keyword is now really working like a let by default. So it is block scoped. Also lets are blocked scope always now (they only where that in specific conditions).”
In practical terms, a const or let declared inside an if block, a for loop, or any other block now genuinely stops existing at the closing brace. That is what the language specification calls for, and it is what the rest of the JavaScript world has been doing for years. If you had code that quietly relied on a const or let leaking out of its block and getting reused later in the function, that code now behaves differently. Ask me how I know.
If you are upgrading from 2025.06 directly to 2026.03, scan your codebase for any code that declares a const or let inside a block and then references it outside that block. The fix is to declare it in the wider scope or rework the logic so the reference stays inside the block. Either way, this is exactly the behavior you wanted in the first place.
When to Use Which
The rule I follow: const by default, let when you must reassign, var only when you specifically need function-scoping for a reason you can articulate. Ninety percent of local variables are const. Counters, accumulators, and state that genuinely changes are let. var is rare enough that seeing it in a code review should prompt a conversation.
Arrow Functions: Useful, With a Sharp Edge
Arrow functions are concise. They have lexical this, which matters in class-based code but matters very little in Servoy because Servoy does not use classes. The main thing they give you is a compact syntax for inline callbacks.
/**@type {Array<String>}*/const aCodes = ['CUST001', 'CUST002', 'CUST003'];
// Classic styleaCodes.forEach(function(sCode) { application.output(sCode);});
// Arrow function styleaCodes.forEach((sCode) => application.output(sCode));Arrow functions shine inside .map(), .filter(), .forEach(), .then(), .catch(), and similar callback-heavy APIs. They reduce the visual noise of the surrounding code so the actual logic stands out.
The Sharp Edge
Here is the rule that will save you a confusing afternoon. Copied verbatim from the 2025.06 release notes: arrow functions do not automatically update in running clients when their source scripts change. Updates require explicit re-registration or client restart.
In practical terms: if you declare a top-level scope method or a form event handler as an arrow function, and then edit the code, running clients will continue to use the old version until they restart.
// Avoid at scope or form level:const doSomething = (sInput) => { // This does not refresh in running clients when edited};
// Prefer at scope or form level:function doSomething(sInput) { // Named function declarations update correctly}Arrow functions also do not appear in the Solution Explorer tree the way named function declarations do. And they do not hoist, so ordering matters in a way that it does not with named declarations.
House-style position: named function declarations at scope and form level. Arrow functions for inline callbacks only. This is the one place where the platform imposes a real constraint, and it is the constraint the other tutorials in this series have been following all along.
Template Literals: Goodbye, Backslash Line Continuation
Template literals use backticks and let you write multi-line strings with interpolation. This is a quality-of-life improvement that matters more than it sounds like it should, because most Servoy code writes a lot of SQL and a lot of user-facing messages.
The Classic Backslash Pattern
/**@type {String}*/const sSQL = "SELECT O.ordh_id, O.ordh_document_number \ FROM crm_order O \ INNER JOIN crm_customer C ON O.cust_id = C.cust_id \ WHERE O.ordh_document_type = ? \ AND O.org_id = ? \ ORDER BY O.ordh_document_number DESC";The trailing backslashes work. But one stray space after a backslash silently breaks the whole thing, and you get to spend fifteen minutes staring at what looks like correct SQL while Servoy tells you the query is malformed.
The Template Literal Version
/**@type {String}*/const sSQL = ` SELECT O.ordh_id, O.ordh_document_number FROM crm_order O INNER JOIN crm_customer C ON O.cust_id = C.cust_id WHERE O.ordh_document_type = ? AND O.org_id = ? ORDER BY O.ordh_document_number DESC`; /**@type {JSDataSet}*/const dsOrders = databaseManager.getDataSetByQuery('myserver', sSQL, [sDocType, globals.org_id], 1000);No backslashes. No silent whitespace bugs. The SQL reads the way SQL is supposed to read.
Interpolation with ${}
Template literals also interpolate expressions with ${} syntax:
/**@type {String}*/const sCustomerName = rCustomer.company_name; /**@type {Number}*/const iOrderCount = fsOrders.getSize();
/**@type {String}*/const sMessage = `Customer ${sCustomerName} has ${iOrderCount} open orders.`;plugins.dialogs.showInfoDialog('Status', sMessage, 'OK');This is clearer than 'Customer ' + sCustomerName + ' has ' + iOrderCount + ' open orders.' and much harder to get wrong.
The Rule You Cannot Forget
Never interpolate user input into a template literal used as SQL. Template literals are just strings. They do not parameterize anything. If you write `WHERE cust_code = '${sUserInput}'`, you have written a SQL injection vulnerability with prettier syntax than the classic version.
Always use ? placeholders for parameters and pass the values in the params array:
/**@type {String}*/const sSQL = ` SELECT C.cust_id, C.company_name FROM crm_customer C WHERE C.cust_code = ? AND C.org_id = ?`; /**@type {JSDataSet}*/const dsCustomer = databaseManager.getDataSetByQuery('myserver', sSQL, [sUserInput, globals.org_id], 100);Template literals improve readability. They do not change the rules of SQL safety.
Destructuring: Unpacking Objects and Arrays
Destructuring lets you pull fields out of objects or arrays into named local variables in one statement. This is mostly a readability win, but it is a real one for functions that take an options object or return multiple values.
Parameter Objects
A common pattern in Servoy is passing a single options object to a function so the call site is self-documenting. Destructuring makes the function itself cleaner:
/** * Searches customers with a configurable set of filters. * @author Gary Dotzlaw * @since 2026-04-12 * @public * * @param {{query:String, maxResults:Number, activeOnly:Boolean}} oParams * @return {JSDataSet} */function searchCustomers(oParams) { /**@type {String}*/ /**@type {Number}*/ /**@type {Boolean}*/ const { query: sQuery, maxResults: iMaxResults = 50, activeOnly: bActiveOnly = true } = oParams;
/**@type {QBSelect<db:/myserver/crm_customer>}*/ const qbCustomer = datasources.db.myserver.crm_customer.createSelect(); qbCustomer.result.add(qbCustomer.columns.cust_id); qbCustomer.result.add(qbCustomer.columns.company_name); qbCustomer.where.add(qbCustomer.columns.org_id.eq(globals.org_id)); qbCustomer.where.add(qbCustomer.columns.company_name.like('%' + sQuery + '%')); if (bActiveOnly) { qbCustomer.where.add(qbCustomer.columns.cust_active.eq(1)); } qbCustomer.sort.add(qbCustomer.columns.company_name.asc);
return databaseManager.getDataSetByQuery(qbCustomer, iMaxResults);}A few things to note about destructuring in Servoy house style:
- Hungarian notation still applies. When you destructure, rename to the Hungarian-prefixed local.
{ query: sQuery }unpacksoParams.queryinto a localsQuery. The destructured variable still needs its prefix. @typeJSDoc on each unpacked variable. One@typecomment per destructured variable, stacked above the declaration. The parser picks them up in order.- Default values with
=.maxResults: iMaxResults = 50gives you a safe default when the caller omits the field. This replaces the classicvar iMaxResults = oParams.maxResults || 50;pattern, which misbehaves when the caller passes0.
Multi-Return Values
JavaScript does not have real multi-return, but array destructuring gets you close:
Promise.all([ databaseManager.getDataSetByQueryAsync(qbCustomers, 1000), databaseManager.getDataSetByQueryAsync(qbOrders, 1000)]).then(function(aResults) { /**@type {Array<JSDataSet>}*/ const [dsCustomers, dsOrders] = aResults; application.output(`Customers: ${dsCustomers.getMaxRowIndex()}, Orders: ${dsOrders.getMaxRowIndex()}`);});Array destructuring is especially nice with Promise.all() because the results come back as a positional array. Unpacking them into named locals makes the .then() callback much easier to read than repeatedly indexing aResults[0], aResults[1].
Optional Chaining and Default Parameters
Two smaller features that eliminate a lot of classic boilerplate.
Optional Chaining (?.)
Instead of this:
/**@type {String}*/var sEmail = '';if (rCustomer && rCustomer.primary_contact_email) { sEmail = rCustomer.primary_contact_email;}Write this:
/**@type {String}*/const sEmail = rCustomer?.primary_contact_email ?? '';?. short-circuits to undefined if any link in the chain is null or undefined. ?? (nullish coalescing) substitutes the fallback value. Together they replace the defensive null-check pyramid that used to dominate Servoy code.
One caveat worth understanding. Optional chaining works cleanly for direct column access like the example above. For relation chains, the behavior is subtler than it looks. rCustomer.someRelation returns a JSFoundSet object, not null, so ?. passes through it even when the related foundset is empty. The chain does not short-circuit on the relation itself. If you need to guard against an empty related foundset, check it explicitly with .getSize() rather than relying on ?. to do the work for you.
Default Parameters
Instead of the || fallback pattern, which fails when the caller passes 0 or an empty string:
function doThing(iMaxRows) { iMaxRows = iMaxRows || 1000; // Broken: caller passing 0 silently gets 1000}Use real default parameters:
/** * @param {Number} iMaxRows maximum rows to return; defaults to 1000 */function doThing(iMaxRows = 1000) { // iMaxRows is 0 if caller passed 0, not 1000}Defaults only apply when the argument is undefined, which is the behavior you almost always want.
The House-Style Position
I want to be clear about something. The modern parser lets you write in a much wider range of styles than the old one did. That does not mean every new style is the right style for Servoy. Here is the position I take on my own projects:
Keep:
- Hungarian notation on every variable
@typeJSDoc annotations on every variable- Full JSDoc headers on every function (
@author,@since,@publicor@private,@param,@return) - Named
functiondeclarations at scope and form level (never arrow functions at that level) try/catchat invocation boundaries and.catch()on every Promise chain- Semicolons, strict equality (
===), explicit radix onparseInt
Adopt:
constas the default,letwhen you must reassign,varonly when function scoping is genuinely needed- Arrow functions for inline callbacks inside
.map,.filter,.forEach,.then,.catch - Template literals for multi-line SQL and any string that interpolates values
- Destructuring for parameter objects and multi-return values
- Optional chaining and nullish coalescing for defensive null handling
- Default parameter values instead of the
||fallback pattern
The modern features improve readability without changing any of the discipline around type annotations, error handling, and tenant isolation. You are not trading safety for brevity; you are trading verbose safety for concise safety.
Before and After: A Real Scope Function
Let’s take a scope function written in classic ES5 style and rewrite it. This is a function that loads a customer, looks up their three most recent orders, and returns a summary object.
Classic ES5 Version
/** * Gets a customer summary with recent orders. * @author Gary Dotzlaw * @since 2026-04-12 * @public * * @param {String} sCustomerId the customer id * @return {Object} summary with customer and recent orders */function getCustomerSummaryClassic(sCustomerId) { /**@type {JSFoundSet<db:/myserver/crm_customer>}*/ var fsCustomer; /**@type {JSRecord<db:/myserver/crm_customer>}*/ var rCustomer; /**@type {String}*/ var sEmail; /**@type {QBSelect<db:/myserver/crm_order>}*/ var qbOrders; /**@type {JSDataSet}*/ var dsOrders; /**@type {Array}*/ var aRecentOrders; /**@type {Number}*/ var i; /**@type {String}*/ var sSQL;
fsCustomer = datasources.db.myserver.crm_customer.getFoundSet(); fsCustomer.loadRecords(sCustomerId); if (fsCustomer.getSize() === 0) { return null; } rCustomer = fsCustomer.getSelectedRecord();
sEmail = ''; if (rCustomer && rCustomer.primary_contact_email) { sEmail = rCustomer.primary_contact_email; }
qbOrders = datasources.db.myserver.crm_order.createSelect(); qbOrders.result.add(qbOrders.columns.ordh_document_number); qbOrders.result.add(qbOrders.columns.ordh_order_date); qbOrders.result.add(qbOrders.columns.ordh_total); qbOrders.where.add(qbOrders.columns.org_id.eq(globals.org_id)); qbOrders.where.add(qbOrders.columns.cust_id.eq(sCustomerId)); qbOrders.sort.add(qbOrders.columns.ordh_order_date.desc); dsOrders = databaseManager.getDataSetByQuery(qbOrders, 3);
aRecentOrders = []; for (i = 1; i <= dsOrders.getMaxRowIndex(); i++) { aRecentOrders.push({ documentNumber: dsOrders.getValue(i, 1), orderDate: dsOrders.getValue(i, 2), total: dsOrders.getValue(i, 3) }); }
return { customerId: sCustomerId, companyName: rCustomer.company_name, email: sEmail, recentOrders: aRecentOrders };}It works. It is also more boilerplate than logic.
Modern Version
/** * Gets a customer summary with recent orders. * @author Gary Dotzlaw * @since 2026-04-12 * @public * * @param {String} sCustomerId the customer id * @return {Object} summary with customer and recent orders */function getCustomerSummary(sCustomerId) { /**@type {JSFoundSet<db:/myserver/crm_customer>}*/ const fsCustomer = datasources.db.myserver.crm_customer.getFoundSet(); fsCustomer.loadRecords(sCustomerId); if (fsCustomer.getSize() === 0) { return null; } /**@type {JSRecord<db:/myserver/crm_customer>}*/ const rCustomer = fsCustomer.getSelectedRecord(); /**@type {String}*/ const sEmail = rCustomer?.primary_contact_email ?? '';
/**@type {QBSelect<db:/myserver/crm_order>}*/ const qbOrders = datasources.db.myserver.crm_order.createSelect(); qbOrders.result.add(qbOrders.columns.ordh_document_number); qbOrders.result.add(qbOrders.columns.ordh_order_date); qbOrders.result.add(qbOrders.columns.ordh_total); qbOrders.where.add(qbOrders.columns.org_id.eq(globals.org_id)); qbOrders.where.add(qbOrders.columns.cust_id.eq(sCustomerId)); qbOrders.sort.add(qbOrders.columns.ordh_order_date.desc); /**@type {JSDataSet}*/ const dsOrders = databaseManager.getDataSetByQuery(qbOrders, 3);
/**@type {Array<Object>}*/ const aRecentOrders = []; for (let i = 1; i <= dsOrders.getMaxRowIndex(); i++) { aRecentOrders.push({ documentNumber: dsOrders.getValue(i, 1), orderDate: dsOrders.getValue(i, 2), total: dsOrders.getValue(i, 3) }); }
return { customerId: sCustomerId, companyName: rCustomer.company_name, email: sEmail, recentOrders: aRecentOrders };}What changed:
- Declarations moved to point of use. No more declaring everything at the top. Each
constsits right next to its initialization. constthroughout. Nothing in this function reassigns, so nothing needslet.let iinside theforheader. The loop index is scoped to the loop, which is what you actually want.rCustomer?.primary_contact_email ?? ''replaces the four-line defensive null check.- Named function declaration stays. This is a scope-level function, so it is declared with
function, not aconstarrow. - The foundset loop is still
for (let i = 1; ...). Foundsets are 1-indexed, andfor...ofdoes not work on them. The classic indexed loop is still the right answer.
Notice what did not change:
- Hungarian notation is still everywhere.
- Every variable still has a
@typeannotation. - The JSDoc header is still complete.
- Tenant isolation via
globals.org_idis still explicit. - The function is still a named declaration at scope level.
The modern features cleaned up the code without compromising any of the conventions that make Servoy code maintainable.
What Comes Next
Modern JavaScript in Servoy is a big topic, and this article covered the language-level features. There is more in the pipeline:
- Promises and async data APIs (next article) covers
databaseManager.getDataSetByQueryAsync(),Promise.all()for parallel queries, and how to handle errors cleanly across async chains. - The typed Query Builder covers the typed
QBColumnsubclasses that shipped in 2025.09.QBTextColumn,QBNumberColumn,QBDatetimeColumn, and friends mean the editor knows which methods apply to which column types and catches mistakes at write time. - The typed Solution Model covers
JSForm.NAMES.*,JSValueList.NAMES.*, and the other typed references that replaced string literals in 2025.12. F3 navigation, find-references, and autocomplete now work on form and valuelist names.
Each of these builds on the language features you now have. Promises are much easier to read with arrow functions and destructuring. Typed Query Builder works hand-in-hand with const-heavy style. Typed Solution Model makes code cleaner the way template literals make SQL cleaner.
That concludes this Servoy tutorial on modern JavaScript. I hope you enjoyed it, and I look forward to bringing you more Servoy tutorials in the future.