
This is a Servoy tutorial on typed Map and Set collections. If you have written Servoy code long enough, you have probably invented your own caching pattern at least once. Maybe it was a plain object with string keys, a dozen utility methods bolted on, and the fervent hope that nobody would ever pass "constructor" as a key. Servoy 2025.09 closed that chapter. The IDE now understands typed collections, which means your cache can tell you exactly what type comes back out of it.
This tutorial targets Servoy 2025.09 or later, which is when the IDE typing for Map<K, V> and Set<V> landed (SVY-18614 [1]). If you are on Servoy 2025.06, Map and Set work at runtime (Rhino 1.8.0 ships with ES6 support [3]), but the IDE gives you no type inference on retrieved values. The full example code runs on 2025.06; the type annotations are the 2025.09 feature.
If you are not already familiar with the modern JavaScript baseline in Servoy, I recommend reading Modern JavaScript in Servoy: ES6+ Features with Rhino 1.8 [6] first, since this tutorial builds on those foundations.
The problem
Let’s imagine for a moment that you need to look up a product name by its numeric ID, hundreds of times per page load. Are you going to hit the database every single time? No, of course not. So you build a cache. The classic approach is to reach for a plain JavaScript object:
// ES5 - the pattern that served us well for a decadevar oLookup = {};oLookup[nProductId] = sProductName;var sName = oLookup[nProductId];This works, and the Using an Object as a Cache [7] tutorial from 2013 shows a full, production-ready version of it. I still think that article holds up for what it is. But the plain-object approach has a handful of problems that get sharper as your code matures:
- All keys are coerced to strings. Pass a numeric ID and JavaScript silently converts it. This matters when
0,"", andfalseall become the same key, or when two IDs that differ only in type collide. - Prototype pollution risk. If any key matches a built-in Object property (
constructor,toString,hasOwnProperty), you are in for a confusing afternoon. You can work around this withObject.create(null), but now you have to remember to do that every time. - No
.size. To count entries you iterate and count manually, or maintain a separate counter. - No
.has(). The idiomobj[key] !== undefinedlies to you when you deliberately storeundefinedas a value. - Zero IDE type inference. The script editor has no idea what type comes back out of
oLookup[nProductId]. Every usage isany, so you get no autocomplete and no type-checking on the value you retrieved.
The ES6 Map solves all five problems. And as of Servoy 2025.09, the IDE understands the generic type parameters so you can annotate Map<Number, String> and the editor knows what .get() returns.
The approach
ES6 gives us two typed collection types:
Map<K, V>is an ordered key/value store where keys can be any type and the IDE knows the value type.Set<V>is an ordered unique-value collection where the IDE knows the element type.
Both use the Hungarian prefixes m for Map and st for Set, with a /**@type {Map<K, V>}*/ or /**@type {Set<V>}*/ annotation above every declaration. Capital-letter Servoy type names, same as everywhere else: String, Number, Boolean, Date.
The type examples given in the Servoy 2025.09 release notes are Map<String, Number> and Set<Date> [1].
In Servoy 2025.12, two more tickets deepened the support: SVY-20595 added iteration type inference (so .entries() and .keys() return properly typed iterators), and SVY-20596 added auto-typed forEach callbacks, meaning the editor infers the callback parameter types from the Map or Set’s declared generics without any extra JSDoc on the callback itself [2].
Scenario one: typed lookup table (Map replaces oCache)
Let’s build a product-name lookup that loads once and serves many callers. This is the core use case where Map shines over the plain-object approach.
Here is the complete code:
// This cache lives at scope level (for example in scopes/cache.js). Persistent// Servoy scope variables are declared with var, not const or let, so they keep// their value for the lifetime of the scope. For a globals or shared scope that// means the whole user session. /**@type {Map<Number, String>}*/var mProductNames = new Map();
/** * Build a product-name lookup Map from a dataset, loading from the database * on first call. Returns from the in-memory Map on subsequent calls. * * @author Gary Dotzlaw * @since 2026-06-11 * @public * * @return {Map<Number, String>} map of product ID to product name */function getProductNameMap() { if (mProductNames.size > 0) { return mProductNames; }
/**@type {QBSelect<db:/myserver/crm_product>}*/ const query = datasources.db.myserver.crm_product.createSelect(); query.result.add(query.columns.product_id); query.result.add(query.columns.product_name); query.where.add(query.columns.org_id.eq(globals.org_id));
// -1 returns all rows: this is a full lookup table, so we deliberately // load every product once and serve the rest of the session from memory. /**@type {JSDataSet}*/ const dsProducts = databaseManager.getDataSetByQuery(query, -1); if (!dsProducts) return mProductNames;
for (let i = 1; i <= dsProducts.getMaxRowIndex(); i++) { /**@type {Number}*/ const nId = dsProducts.getValue(i, 1); /**@type {String}*/ const sName = dsProducts.getValue(i, 2); mProductNames.set(nId, sName); } return mProductNames;}
/** * Look up a product name by its numeric ID. * * @author Gary Dotzlaw * @since 2026-06-11 * @public * * @param {Number} nProductId the numeric product ID * @return {String|null} the product name, or null if not found */function getProductName(nProductId) { /**@type {Map<Number, String>}*/ const mNames = getProductNameMap(); if (mNames.has(nProductId)) { return mNames.get(nProductId); } return null;}
/** * Clear the product-name lookup cache so the next call reloads from the database. * * @author Gary Dotzlaw * @since 2026-06-11 * @public * * @return {void} */function clearProductNameCache() { mProductNames.clear();}The code is doing several things that are worth calling out:
var mProductNames = new Map()at scope level: The cache is declared withvar, notconst, and it lives at the top of a scope file (for examplescopes/cache.js). That is deliberate. In Servoy, a persistent scope variable, the kind that keeps its value across calls for the lifetime of the scope, is declared withvar. Aconstorletat the top of a scope file is a module-local binding and is not guaranteed to give you scope-variable persistence. Since this Map is the whole point of a session cache,varis the correct choice here. This is the one place the conventions doc’s “var only when scope-level persistence is required” rule applies.databaseManager.getDataSetByQuery(query, -1): The-1means return all rows. This is a full lookup table, so loading every product once on the first call is the intended behaviour, not an unbounded-load mistake. Every subsequent call returns straight from the in-memory Map thanks to the size guard at the top of the function./**@type {Map<Number, String>}*/: This annotation tells the Servoy IDE that keys areNumberand values areString. When you callmProductNames.get(nId), the editor knows the return type isString, notany. That is the whole point of the tutorial.mProductNames.set(nId, sName): The.set()call is chainable and returns the Map, but we don’t need the return here. The key remains aNumber; no string coercion happens.mNames.has(nProductId)before.get(): Always check.has()before.get()when a missing key is a valid business condition..get()returnsundefinedfor missing keys, and if your IDE type says the value isString, thatundefinedcan be a surprise.mProductNames.clear(): One call drops every entry. No iteration required.
Here is how callers use it:
/** * Build a display string for a line item. * * @author Gary Dotzlaw * @since 2026-06-11 * @private * * @param {Number} nProductId the product ID * @param {Number} nQty the ordered quantity * @return {String} display string */function buildLineItemLabel(nProductId, nQty) { /**@type {String|null}*/ const sName = getProductName(nProductId); if (!sName) return `Product ${nProductId} (unknown)`; return `${sName} x ${nQty}`;}The editor knows sName is String|null because the return type of getProductName is annotated. You get autocomplete on string methods and a type error if you pass sName somewhere that expects a non-null String. That is the lookup table that doesn’t lie.
Comparing with the oCache pattern
The old oCache approach took roughly 50 lines of boilerplate to achieve what the typed Map achieves in a handful of lines. Here is the comparison at a glance:
| Capability | oCache (plain object) | ES6 Map |
|---|---|---|
| Key types | String only (coerced) | Any type |
| Prototype collision risk | Yes | No |
.size | Manual counter needed | Built-in |
.has() | obj[k] !== undefined (fragile) | Built-in |
| IDE type on retrieved value | None (any) | Full (from generic annotation) |
| Lines of boilerplate | 30-50 | 0 |
The oCache approach was the right tool in 2013 when ES6 did not exist in Servoy. In 2025.09+, the right tool is a typed Map. The typed Map belongs to the same IDE-typing wave as the Typed Query Builder [8] and the Typed Solution Model [9]: each one replaces a stringly-typed or untyped construct with an annotation the editor actually understands.
Scenario two: typed Set for deduplication
Now let’s look at Set. The canonical use case is collecting unique values without worrying about duplicates.
Imagine you need to know which unique category codes appear in a product foundset. With a plain array you would check indexOf on every add. With a Set you just call .add() and let the Set handle uniqueness.
/** * Collect the unique category codes from a product foundset. * * @author Gary Dotzlaw * @since 2026-06-11 * @public * * @param {JSFoundSet<db:/myserver/crm_product>} fsProducts the product foundset to scan * @return {Set<String>} the unique category codes found */function getUniqueCategoryCodes(fsProducts) { /**@type {Set<String>}*/ const stCodes = new Set(); for (let i = 1; i <= fsProducts.getSize(); i++) { /**@type {JSRecord<db:/myserver/crm_product>}*/ const rProd = fsProducts.getRecord(i); /**@type {String}*/ const sCategoryCode = rProd.category_code; stCodes.add(sCategoryCode); } return stCodes;}The /**@type {Set<String>}*/ annotation tells the editor that every value stored in stCodes is a String. Calling .has() on it is type-checked. After running this function, you have an ordered collection of unique category codes, deduplicated automatically, with no manual indexOf check.
Here is a usage example that demonstrates .has(), .size, and .delete():
/** * Check whether a product foundset contains any items in the "clearance" category * and, if so, remove clearance from the code set for the purposes of reporting. * * @author Gary Dotzlaw * @since 2026-06-11 * @public * * @param {JSFoundSet<db:/myserver/crm_product>} fsProducts the foundset to inspect * @return {Set<String>} the non-clearance category codes */function getNonClearanceCodes(fsProducts) { /**@type {Set<String>}*/ const stCodes = getUniqueCategoryCodes(fsProducts);
/**@type {String}*/ const sClearanceCode = 'CLRNC';
if (stCodes.has(sClearanceCode)) { stCodes.delete(sClearanceCode); application.output('Removed clearance category from report set.', LOGGINGLEVEL.DEBUG); } application.output('Category codes in report: ' + stCodes.size, LOGGINGLEVEL.DEBUG); return stCodes;}The Set API is clean and intentional: .add() to insert, .has() to test membership, .delete() to remove a specific value, and .size to count entries [5].
Iterating with forEach: the value-first gotcha
Both Map and Set support forEach, and both will surprise you if you assume the callback signature mirrors what you’d expect from an array.
For Map.forEach, the callback receives (value, key, map), value FIRST, then key [4]. This is the opposite of the natural reading order and is a common source of bugs. I’ll say it again to make sure it sticks: value first, key second.
Here is the correct pattern:
/** * Log all product name entries in the lookup Map for debugging. * * @author Gary Dotzlaw * @since 2026-06-11 * @private * * @param {Map<Number, String>} mNames the product name Map to log * @return {void} */function logProductNames(mNames) { mNames.forEach((sName, nId) => { // NOTE: value (sName) comes FIRST, key (nId) comes SECOND. // This is Map.forEach behavior per the ES6 spec [4]. // DEBUG-only private helper; per-item output inside the loop is acceptable here. application.output(`Product ${nId}: ${sName}`, LOGGINGLEVEL.DEBUG); });}In Servoy 2025.12 with SVY-20596, the callback parameters sName and nId are automatically inferred from the Map’s declared generic type [2]. On 2025.09, before that auto-inference landed, you can assign the callback parameters to typed locals inside the callback body (for example /**@type {String}*/ const sNameTyped = sName;) if you want the editor to track their types, since an inline arrow parameter list has no line above each parameter to annotate.
For Set.forEach, the signature is function(value, value, set) where the first and second parameters are both the value [5]. The duplicate is there for API consistency with Map.forEach. In practice you only use the first parameter:
/** * Log all category codes in a Set for debugging. * * @author Gary Dotzlaw * @since 2026-06-11 * @private * * @param {Set<String>} stCodes the Set of category codes * @return {void} */function logCategoryCodes(stCodes) { stCodes.forEach((sCode) => { // DEBUG-only private helper; per-item output inside the loop is acceptable here. application.output('Category code: ' + sCode, LOGGINGLEVEL.DEBUG); });}forEach is the recommended iteration style in this codebase. The ES6 for...of loop also works on Map and Set (both implement Symbol.iterator in Rhino 1.8.0), but note that Rhino’s ES6 support does not include iterator closing on break, so a for...of loop that exits early via break may not trigger iterator cleanup. Using forEach sidesteps this entirely, and it is what house conventions recommend for collections.
Scope-level Maps as session caches
A Map or Set declared at scope level in Servoy (for example, in globals.js) persists for the lifetime of that scope, which for the globals scope means the entire user session. This makes scope-level Maps a natural fit for session-duration caches: load once on first call, serve from memory for the rest of the session, clear on logout or on a business event that invalidates the data.
Keep in mind that a scope-level Map is in-memory storage only. It is not persisted when the Servoy application restarts, and JSON.stringify(new Map()) returns "{}", so you cannot round-trip a Map through a dataprovider or a database column directly. To persist a Map’s contents, convert to a plain object or array first.
A note on JSRecord-valued Maps
A natural question is whether you can annotate a Map with a JSRecord value type, like Map<String, JSRecord<db:/myserver/crm_customer>>, to build a typed record cache. The JSRecord<db:/...> compound type is supported in scalar @type annotations for foundsets, so the mechanism is plausible. However, the 2025.09 release notes confirm the generic syntax with scalar type examples (Map<String, Number>, Set<Date>), and the specific form Map<String, JSRecord<db:/myserver/crm_customer>> has not been confirmed verbatim in vendor documentation as of the date of this tutorial. You can try it in your own 2025.09+ installation. If the IDE honours it, you get typed record retrieval; if not, annotate the value as JSRecord<db:/myserver/crm_customer> on the call site instead.
For this tutorial I use scalar value types (Map<Number, String>, Set<String>) as the primary showcase because the core teaching rests on the confirmed 2025.09 syntax.
Also worth knowing: the “tab-panels-map” article uses an Array
If you have read the tutorial titled “Add Forms to Tab Panels using a Map,” note that the word “map” in that title is colloquial: the technique uses a plain JavaScript Array to maintain an ordered list of tab names, not an ES6 Map. It is a naming coincidence. The ES6 Map type this tutorial covers is a different thing entirely.
Putting the API in one place
For quick reference, here are the methods you will reach for most often.
Map methods (source: MDN [4], Servoy 2025.09+ [1]):
| Method / Property | What it does |
|---|---|
map.size | Count of key/value pairs |
map.set(key, value) | Add or update an entry; returns the Map (chainable) |
map.get(key) | Retrieve value by key; returns undefined if absent |
map.has(key) | true if the key exists |
map.delete(key) | Remove the entry; returns true if it was found |
map.clear() | Remove all entries |
map.keys() | Iterator of keys in insertion order |
map.values() | Iterator of values in insertion order |
map.entries() | Iterator of [key, value] pairs in insertion order |
map.forEach(fn(value, key)) | Iterate all entries; value first, key second |
Set methods (source: MDN [5], Servoy 2025.09+ [1]):
| Method / Property | What it does |
|---|---|
set.size | Count of values |
set.add(value) | Add the value if not already present; returns the Set |
set.has(value) | true if the value exists |
set.delete(value) | Remove the value; returns true if it was found |
set.clear() | Remove all values |
set.values() | Iterator of values in insertion order |
set.forEach(fn(value)) | Iterate all values |
Key equality for both uses the SameValueZero algorithm: NaN equals NaN, and objects are compared by reference (two distinct objects with identical properties are not equal) [4].
Practical advantages
Using typed Maps and Sets instead of plain objects has several advantages:
- IDE type safety. The
@typeannotation means the editor knows what.get()returns. You get autocomplete on the value and a type error if you misuse it. - Clean, explicit API.
.has(),.get(),.set(),.delete(),.size, and.clear()are purpose-built methods. No workarounds for counting, checking existence, or avoiding prototype collisions. - Any-type keys. Numeric IDs stay numeric. Boolean flags stay boolean. Object references work as keys. No silent string coercion.
- Insertion-order iteration. Both
MapandSetmaintain insertion order, which is now guaranteed by the ES6 spec. - Deduplication is automatic with
Set. No manualindexOfchecks. - Less boilerplate. The old oCache pattern needed 30 to 50 lines to achieve what
new Map()gives you in one.
Conclusion
That concludes this Servoy tutorial on typed Maps and Sets. I hope you enjoyed it, and I look forward to bringing you more Servoy tutorials in the future.
References
[1] Servoy, “Servoy 2025.09 Release Notes (SVY-18614: Typed Map and Set Support),” Servoy Documentation, 2025. https://docs.servoy.com/release-notes/release-notes/2025.09
[2] Servoy, “Servoy 2025.12 Release Notes (SVY-20595, SVY-20596: Strong Typing for Collections),” Servoy Documentation, 2025. https://docs.servoy.com/release-notes/release-notes/2025.12
[3] Mozilla Rhino, “Release Rhino 1.8.0,” GitHub, 2025. https://github.com/mozilla/rhino/releases/tag/Rhino1_8_0_Release
[4] MDN Web Docs, “Map,” Mozilla, 2024. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map
[5] MDN Web Docs, “Set,” Mozilla, 2024. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set
[6] G. Dotzlaw, “Modern JavaScript in Servoy: ES6+ Features with Rhino 1.8,” dotzlaw.com, 2026. /insights/servoy-tutorial-17-modern-javascript/
[7] G. Dotzlaw, “Using an Object as a Cache,” dotzlaw.com, 2013. /insights/servoy-tutorial-object-cache/
[8] G. Dotzlaw, “Typed Query Builder in Servoy,” dotzlaw.com, 2026. /insights/servoy-tutorial-19-typed-query-builder/
[9] G. Dotzlaw, “Typed Solution Model in Servoy,” dotzlaw.com, 2026. /insights/servoy-tutorial-20-typed-solution-model/
Building production AI, or modernizing a legacy system?
That is the kind of work we do at Dotzlaw Consulting. Book a free 20-minute intro call and tell us what you are trying to build, or what is slowing you down.