2448 words
12 minutes
Typed Query Builder: QBTextColumn, QBNumberColumn, and Friends

Typed Query Builder tutorial hero image

This is a Servoy Tutorial on the typed Query Builder column classes that landed in Servoy 2025.09. If you have been writing QBSelect queries for any length of time, you have probably had the experience of calling .substring() on a column that turned out to hold a number, or .year on a column that turned out to hold a string, and only discovering the mistake when the database threw an error at runtime. The 2025.09 release ended that category of bug. The Query Builder now knows what type each column is, and the editor stops you before you hit save.

I want to be clear about what this tutorial is and is not. This is not a “why QBSelect over plain SQL” piece. That tutorial is coming separately, and it covers the foundational reasons every Servoy developer should be reaching for QBSelect first. This tutorial assumes you already know your way around createSelect(), qb.where.add(), and qb.sort.add(). If those are unfamiliar, start there and come back. What this tutorial covers is the upgrade: what changed in 2025.09, why it matters, and how to take advantage of it without breaking your existing code.

What Actually Changed#

Before 2025.09, every column you accessed through qb.columns was the same type: QBColumn. That class had every method on it. .substring(), .year, .upper, .abs, .month, .trim. All of them, all the time. The editor showed you the entire kitchen sink no matter what column you were working with. If you accessed qb.columns.task_price.substring(0, 10), the editor showed you no warnings. The query compiled. Then your database threw a type mismatch at runtime.

Starting in 2025.09, qb.columns.task_price is no longer a QBColumn. It is a QBNumberColumn. And QBNumberColumn does not have a .substring() method on it. The editor knows this. Code completion shows you only the methods that make sense for a number column. If you try to call .substring() on it, you get an editor warning before you even run the code.

The official 2025.09 release notes put it like this: “QBTextColumn exposes a string function, substring, but a QBNumberColumn does not.” That is the entire feature in one sentence. But the consequences are bigger than the sentence makes it sound. Make sense?

The Five Typed Subclasses#

Servoy ships five concrete column subclasses, each tied to a category of column types in your data model:

QBTextColumn is for character types. char, varchar, nvarchar, text. It exposes the string functions: .upper, .lower, .trim, .substring(pos), .substring(pos, len), .len, .like, .not.like, .locate, and .concat(value) which concatenates the column with the given value and returns a new QBTextColumn. For the form that concatenates two arbitrary expressions independently of any column, use qb.functions.concat(a, b). None of these text-specific methods exist on the number, integer, datetime, or media subclasses.

QBIntegerColumn is for whole-number types. int, smallint, bigint. It exposes aggregate functions: .sum, .count, .avg, .max, .min. The math property .abs is available here. The rounding-and-flooring properties .round, .floor, .ceil also appear on integer columns; on an integer they are essentially no-ops, but they are present for API uniformity so generic code that targets QBNumberColumn | QBIntegerColumn reads the same on either.

QBNumberColumn is for floating-point and decimal types. float, decimal, numeric, double. It exposes the aggregates you would expect on a numeric column: .sum, .avg, .max, .min. Notice the absence of .count on this list: counting only makes sense on whole-number cardinality, not on a decimal value, so the typed API leaves it off. It also exposes math properties: .abs, .round, .floor, .ceil, .sqrt. One subtle point worth noting: .round, .floor, and .ceil on a number column return a QBIntegerColumn, since rounding a number produces a whole number. If you need a decimal result with controlled precision, use the two-argument form on the functions side, qb.functions.round(col, decimals), which returns QBNumberColumn. The rounding properties are not exposed on QBTextColumn or QBDatetimeColumn, where they would not make sense.

QBDatetimeColumn is for date and time types. datetime, timestamp, date. It exposes the date-extraction properties: .year, .month, .day, .hour, .minute, .second. None of these are valid on text or number columns.

QBMediaColumn is for binary types. blob, media. Its operations are limited because there is not much you can meaningfully do with a blob in a WHERE clause beyond .isNull and .eq.

There is a related class worth mentioning, added separately with the AI plugin work in Servoy 2025.12: QBVectorColumn. This one is for the pgvector column type. Its methods are .vector_score(embedding) and .vector_distance(embedding). The 2025.09 release notes name only the five typed subclasses above; QBVectorColumn shipped with the vector / embedding work later. We will not cover vector search in this tutorial, but if you are working with embeddings and semantic search, the AI plugin tutorial series goes into detail there.

All of these subclasses still inherit the universal column operations: .eq, .gt, .lt, .ge, .le, .between, .isNull, .isin, .asc, .desc. Those are valid on every column type. The split is only about the type-specific operations.

A Real Bug The Old API Couldn’t Catch#

Let me show you a bug that I have personally written more than once. I am working on a customer search form. The user enters a partial customer name. I want to do a case-insensitive prefix match. Here is what I write:

/**
* Search customers by name (case-insensitive prefix match).
* NOTE: This function intentionally contains a bug for the article walkthrough.
* @author Gary Dotzlaw
* @since 2026-04-27
* @public
*
* @param {String} sSearchTerm the user-entered partial customer name
*/
function searchCustomers(sSearchTerm) {
/**@type {QBSelect<db:/myserver/crm_customer>}*/
const qbCust = datasources.db.myserver.crm_customer.createSelect();
qbCust.result.addPk();
qbCust.where.add(qbCust.columns.org_id.eq(globals.org_id));
qbCust.where.add(qbCust.columns.cust_id.upper.like(sSearchTerm.toUpperCase() + '%'));
qbCust.sort.add(qbCust.columns.cust_name.asc);
foundset.loadRecords(qbCust);
}

Spot the bug? I meant cust_name. I typed cust_id. In this schema cust_id is an integer; the point is that whatever its actual type, it is not the text I wanted. Pre-2025.09, this would compile silently. The editor would happily show me the .upper and .like methods because every column had them. The query would only fail at runtime, with a database error that I would have to trace back to this line.

In 2025.09 and later, qbCust.columns.cust_id is a QBIntegerColumn. It does not have .upper. The editor underlines .upper with a warning before I even save the file. I notice the bug in five seconds instead of finding it in production three weeks later.

This is the part that is hard to appreciate until you have lived through the alternative. Type-checking does not catch every bug. It catches a specific category of bug, and that category happens to include some of the most embarrassing ones. The ones where you knew what column you wanted, your finger slipped, and now you are debugging a SQL error in a screen full of similar columns with similar names.

How To Get The Type Information#

The typed columns work automatically as long as the editor knows what table your QBSelect is for. There are two ways to make that happen.

The cleanest way is the JSDoc generic annotation:

/**@type {QBSelect<db:/myserver/crm_task>}*/
const qbTask = datasources.db.myserver.crm_task.createSelect();

That <db:/myserver/crm_task> part inside the type annotation is what tells the editor which table this query targets. Once that is in place, every column access through qbTask.columns.something knows what type that column actually is. Code completion adapts. Methods that do not apply are hidden. Methods that do apply show their proper return types.

The other way is implicit. When you call datasources.db.myserver.crm_task.createSelect() directly, the editor can usually infer the type from the call chain itself. The JSDoc annotation is still good practice because it makes the type explicit at the variable level and survives refactoring, but the inference works for simple cases.

For joins, the same pattern applies:

/**@type {QBSelect<db:/myserver/crm_order>}*/
const qbOrder = datasources.db.myserver.crm_order.createSelect();
qbOrder.result.addPk();
/**@type {QBJoin<db:/myserver/crm_customer>}*/
const joinCust = qbOrder.joins.add('db:/myserver/crm_customer', QBJoin.INNER_JOIN, 'cust');
joinCust.on.add(joinCust.columns.cust_id.eq(qbOrder.columns.cust_id));
qbOrder.where.add(joinCust.columns.cust_name.like('%' + sSearchTerm + '%'));
qbOrder.where.add(qbOrder.columns.org_id.eq(globals.org_id));
foundset.loadRecords(qbOrder);

The join variable is annotated as QBJoin<db:/myserver/crm_customer>, and now joinCust.columns.cust_name is correctly identified as a QBTextColumn. The .like() method is valid. If I had tried joinCust.columns.cust_id.like(...) on the integer PK, the editor would have flagged it.

Functions That Return Typed Columns#

The typed system is not just on the column accessors. It also flows through qb.functions. When you call qb.functions.upper(someColumn), the return value is a QBTextColumn, regardless of what type the input was. When you call qb.functions.year(someColumn), the return value is a QBIntegerColumn. This means you can chain typed operations correctly:

/**@type {QBSelect<db:/myserver/crm_order>}*/
const qbOrder = datasources.db.myserver.crm_order.createSelect();
qbOrder.result.addPk();
qbOrder.where.add(qbOrder.columns.org_id.eq(globals.org_id));
// Match orders placed in a specific year
qbOrder.where.add(qbOrder.columns.order_date.year.eq(2026));
// Or using the equivalent functions form
qbOrder.where.add(qbOrder.functions.year(qbOrder.columns.order_date).eq(2026));
foundset.loadRecords(qbOrder);

Both forms work. The first uses the .year property directly on the QBDatetimeColumn. The second uses qb.functions.year() and passes the column in. Either way, the result is a QBIntegerColumn, and you can compare it to a number using .eq(). If you tried to compare it to a string, the type system would notice.

Here is a list of common return types you can rely on:

  • qb.functions.upper(col), .lower(col), .trim(col), .substring(col, pos), .substring(col, pos, len), .concat(a, b) all return QBTextColumn.
  • qb.functions.year(col), .month(col), .day(col), .hour(col), .minute(col), .second(col), .len(col), .locate(...), .bit_length(col), .floor(col), .ceil(col) all return QBIntegerColumn.
  • qb.functions.abs(col), .divide(a, b), .multiply(a, b), .plus(a, b), .minus(a, b), .sqrt(col), .round(col, decimals) return QBNumberColumn.
  • qb.functions.cast(col, type), .coalesce([...]), .nullif(a, b), .custom(name, args) return QBGenericColumn because their type depends on inputs the editor can’t always determine.

The point is not to memorize this table. The point is that when the editor knows the type of an expression, code completion and warnings get smarter. You can trust them.

What About DATEPART Quarters and Weekdays?#

Servoy’s QBDatetimeColumn exposes .year, .month, .day, .hour, .minute, and .second. That is the full list. There is no .quarter, no .dayofweek, no .dayofyear, and no .week. This is not an oversight in the typed columns; the underlying Query Builder engine simply does not expose those parts. Different database vendors implement them differently, and the Query Builder sticks to the parts that every supported database handles the same way.

If you need quarter or week extraction, you do it the way you have always done it: define a custom function on the database side and call it through qb.functions.custom():

-- On the SQL Server side, define a function:
CREATE FUNCTION dbo.fn_quarter_of (@input datetime) RETURNS int AS
BEGIN
RETURN DATEPART(quarter, @input);
END
/**@type {QBSelect<db:/myserver/crm_order>}*/
const qbOrder = datasources.db.myserver.crm_order.createSelect();
qbOrder.result.addPk();
qbOrder.where.add(qbOrder.columns.org_id.eq(globals.org_id));
// Filter to Q4 orders
qbOrder.where.add(
qbOrder.functions.custom('fn_quarter_of', [qbOrder.columns.order_date]).eq(4)
);
foundset.loadRecords(qbOrder);

The return type is QBGenericColumn, so you lose some of the type-checking benefit on this expression specifically. That is the tradeoff for using a database-side custom function. Your .eq(4) will still work because every column subclass has .eq(), but the editor cannot tell you whether 4 is a valid value for whatever your function returns.

Migrating Existing Code#

Here is the good news: you do not have to migrate anything. If your existing QBSelect code worked before 2025.09, it still works after. The typed subclasses are subclasses of QBColumn. Every method that was on QBColumn is still inherited. Your old code does not care which subclass it gets back.

What changes is the editor. After upgrading, you will start seeing warnings on lines that used to be silent. Some of those warnings will be real bugs you never noticed. A few might be cases where the type system is being conservative and you are using a function in a way that the typed API does not anticipate. Both are worth investigating.

The pragmatic approach: upgrade to 2025.09 or later, then walk through the editor warnings on your QBSelect-heavy modules. The first few will be eye-opening. The rest will go quickly because the patterns repeat. You will end up with code that is more correct, and you did not have to rewrite anything to get there.

There is one specific pattern worth watching for. If you have older code that aliases columns to a generic variable for reuse, like:

// Antipattern: aliasing a typed column to a generic name discards the type information.
const colSearchable = qbCust.columns.cust_id; // an integer column
// ...later...
qbCust.where.add(colSearchable.upper.like(sTerm)); // pre-2025.09: silent, runtime error
// 2025.09+: editor warning

You will get warnings on those reuses. Fix the variable to point to the right column, or change the operation to one that is valid for the actual column type. Either way, the bug surfaces at edit time instead of in production.

When To Add The JSDoc Annotation#

Code completion in Servoy is smart enough to infer types in most cases when you write code in the obvious style. The annotation matters most in three situations.

First, when you assign the QBSelect to a variable and then pass that variable around through several functions. The annotation on the variable lets the editor follow the type across function boundaries.

Second, when you use foundset.getQuery(). That method’s return type is QBSelect<unknown> until you tell the editor what table the foundset is on. Adding /**@type {QBSelect<db:/myserver/crm_task>}*/ brings the typed column information back:

/**@type {QBSelect<db:/myserver/crm_task>}*/
const qbTask = foundset.getQuery();
qbTask.where.add(qbTask.columns.task_name.like('%' + sSearchTerm + '%'));
foundset.loadRecords(qbTask);

Third, on join variables. The qb.joins.add(...) return type is generic, so the typed columns on a join only become available after you annotate the join variable as QBJoin<db:/myserver/whatever_table>.

Annotate at the variable declaration, not at the function parameter level. Function parameters can be declared too, but the variable-level declaration is what makes the columns lookup work properly inside the function body.

What This Means For Your Workflow#

The benefit of typed columns is not a single dramatic moment. It is a quiet, continuous reduction in the number of bugs that escape from your editor into your test environment. You spend less time chasing “why does this query throw a type error in production.” You spend less time reading the Servoy column documentation to remember whether task_priority is an int or a varchar. The editor remembers for you.

For developers working with AI agents, there is an additional benefit. When an AI agent generates QBSelect code, it is now constrained by the same type information that constrains a human developer. If the agent tries to call .substring() on a number column, the editor flags it the same way. The agent’s first suggestion may be wrong, but the loop between “agent generates code” and “code has obvious errors” closes much faster. We will return to this in the upcoming AI agent tutorial when we look at how to feed the QBSelect skill into the agent’s context window.

That concludes this Servoy tutorial on the typed Query Builder column classes. If you have not yet upgraded to 2025.09 or later, this is one of the more practically useful upgrades in recent memory: a real reduction in a real category of bugs, with no migration cost beyond reading the editor warnings that show up on your existing code. If you are already on 2025.09+ and you have not been paying attention to the column types, take ten minutes to look at one of your QBSelect-heavy methods and see what the editor has been quietly telling you. There may be a bug or two waiting in there. I hope you enjoyed it, and I look forward to bringing you more Servoy tutorials in the future.

Cheers!

Typed Query Builder: QBTextColumn, QBNumberColumn, and Friends
https://dotzlaw.com/insights/servoy-tutorial-19-typed-query-builder/
Author
Gary Dotzlaw
Published at
2026-05-10
License
CC BY-NC-SA 4.0
← Back to Insights