Built-in Objects in a Function

Every function body is given a set of ready-made objects. You do not import or declare them — they are injected before the code runs, and the set is the same in Groovy, Python and JavaScript functions.

Object Purpose
ctx the context — input data, addressed by dotted paths
higson decision tables and other functions
log logger, scoped to this function
str string helpers
util comparisons, collections, hashes, null checks
math mathematical helpers
date date arithmetic, parsing and formatting
type conversions between Higson types and Java types
domain domain elements and their attributes

higson is covered in Working with Decision Table Results. This page covers the rest.

util is not the same as Util Functions. The util object described here is a Java helper available inside a function body. Util Functions are configuration-level functions called with $u notation from Domain Configuration and Decision Tables. The names overlap; the two have nothing to do with each other.

Logging - log#

The logger is created per function, so its name identifies which function produced a line, and its level can be controlled per function at runtime.

log.info("Premium {} for driver aged {}", premium, age)
log.debug("Factor: {}", factor)
log.warn("No tariff row matched")
log.error("Cannot price: {}", reason)
log.trace("Intermediate: {}", value)

It follows slf4j conventions: {} placeholders substituted from the trailing arguments, not string interpolation. Level guards are available for expensive messages:

if (log.isDebug()) {
    log.debug("Full matrix: {}", matrix)
}

Methods: trace · debug · info · warn · error, each with the matching isTrace() / isDebug() / isInfo() / isWarn() / isError() guard, plus getName().

Strings — str#

str extends Apache Commons Lang3 StringUtils, so the whole of that class is reachable through it as well — str.isNotBlank, str.substringBefore, str.substringBetween, str.leftPad, str.equalsIgnoreCase and the rest. The methods below are the Higson additions on top of it.

Most are null-tolerant, but not all: str.trunc, both str.split overloads and str.contains throw a NullPointerException on a null argument. str.trunc also throws when maxlen is below 3 and the input is longer than maxlen.

Padding and trimming. padLeft and padRight are named after the alignment of the original text, not the side the padding lands on — padLeft appends, padRight prepends. For a zero-padded number you want padRight.

str.padLeft('7', '0' as char, 3)   // '700' — left-ALIGNED, padding added at the END
str.padRight('AB', 5)              // '   AB' — right-ALIGNED, padding added at the FRONT
str.trunc(description, 40)    // cut to at most 40 characters
str.trim(value)               // null-safe trim
str.compactSpaces(text)       // collapse runs of whitespace

Joining and splitting

str.concat(['A', 'B', 'C'], ', ')          // 'A, B, C'
str.concat(arr, ', ', '-')                  // '-' substituted for null elements
str.split('a;b;c', ';' as char)             // ['a', 'b', 'c']
str.split(line, ';' as char, 3)             // at most 3 parts

Filtering characters

str.filterDigits(pesel)             // keep digits only
str.filterChars(code, 'A', 'Z')     // keep characters in a range
str.filterChars(code, 'ABC123')     // keep characters from a set
str.rejectChars(iban, ' -')         // drop the listed characters

Null handling and formatting

str.nvl(value)              // '' when null
str.notNull(value)          // '' when null — same behaviour
str.format('{}: {}', label, amount)     // slf4j-style {} anchors, NOT %s
str.capitalizeFirst(name)
str.capitalizeAll(name)

Inspection

str.len(value)                    // length, null-safe
str.count(text, 'x' as char)
str.contains(list, 'FULL')        // works on a Collection or a String[]
str.getCommonStart(a, b)          // longest shared prefix
str.repeat('-' as char, 20)
str.print(rows, 'Matched rows')       // multi-line dump, handy in log messages
str.print(rows, 'Matched rows', 20)   // capped at 20 lines — prefer this for a big matrix

Comparisons, Collections, Hashes — util#

Comparisons that survive mixed types and nulls. util.eq is not ==. It compares numerically whenever both sides parse as numbers — including two strings, so util.eq('10', '10.0') is true and util.gt('9', '10') is false. Only when the parse fails does it fall back to lexical order. Two dates compare chronologically. null sorts below any value, and two nulls are equal.

util.eq(a, b)     util.gt(a, b)     util.ge(a, b)
util.lt(a, b)     util.le(a, b)
util.in(code, ['FULL', 'MINI'])

Emptiness

util.isEmpty(value)     util.notEmpty(value)
util.isBlank(value)     util.notBlank(value)
util.nvl(value, fallback)

Collections and arrays — useful when a function has to build a structure to return:

util.list('A', 'B')
util.stringList('A', 'B')
util.integerList(1, 2)
util.map('k1', 'v1', 'k2', 'v2')
util.linkedHashMap('k1', 'v1')      // also: hashMap, treeMap
util.set('A', 'B')                  // also: hashSet, treeSet
util.arrayList('A', 'B')            // explicitly an ArrayList, when the result gets mutated
util.stringArray('A', 'B')
util.stringArray(5)                 // sized: empty String[5]; same for integerArray, longArray
util.integerArray(1, 2)
util.matrix(3, 4)                   // empty 3x4 Object[][]

Sorting

util.sort(list)                        // natural order
util.sort(list, comparator)
util.sortReversed(list)

Conversions

util.getInt(o)      util.getInteger(o)
util.getString(o)   util.getBigDecimal(o)
util.toUpperCase(o) util.toLowerCase(o)   util.length(o)
util.trim(o)        // null-safe toString().trim() on ANY object, not only a String

Hashes — for building stable keys or anonymising identifiers:

util.sha256(pesel)
util.sha256('|', [firstName, lastName, birthDate])
util.sha1(pesel)
util.md5(pesel)
util.hash('SHA-512', '|', [firstName, lastName])

Geo

util.calculateHaversineDistance(lat1, lon1, lat2, lon2)                  // METERS
util.calculateHaversineDistance(lat1, lon1, elev1, lat2, lon2, elev2)   // with elevation

Numbers — math#

math mirrors java.lang.Math and adds scaled rounding:

math.round(value, 2)     // double, 2 decimal places — see the note below
math.trunc(value, 2)     // double, truncated
math.round(value)        // long
math.abs(v)   math.min(a, b)   math.max(a, b)   math.signum(v)
math.pow(a, b)   math.sqrt(v)  math.cbrt(v)   math.exp(v)
math.log(v)   math.log10(v)   math.log(base, v)
math.ceil(v)  math.floor(v)
math.sin(v)   math.cos(v)   math.tan(v)   math.asin(v)  math.acos(v)  math.atan(v)
math.sinh(v)  math.cosh(v)
math.toRadians(deg)   math.toDegrees(rad)   math.random()
math.PI   math.E

math.round(v, scale) is not BigDecimal.HALF_UP: it always rounds a halfway value toward positive infinity and adds a small epsilon before rounding, so -1.005 and 1.005 move the same direction. That plus the double return makes it the wrong tool for money — prefer _dec(value, 2) or read the figure as BigDecimal in the first place — see Best Practices.

Dates — date#

Parsing. date.parse recognises yyyy-MM-dd and dd-MM-yyyy with -, . or / separators, so most input formats need no pattern:

date.parse('2026-03-15')
date.parse('15.03.2026')
date.parse(value, 'yyyyMMdd')      // explicit pattern
date.parseNoEx(value)              // null instead of an exception on bad input
date.parseNoEx(value, 'yyyyMMdd')  // same, with an explicit pattern
date.parseYMD(s)   date.parseDMY(s)
date.parseLong(s)                  // the LONG FORMAT 'yyyy-MM-dd HH:mm:ss', not epoch millis

Formatting. date.format(d) produces yyyy-MM-dd:

date.format(d)                  // '2026-03-15'
date.formatDMY(d)               // '15-03-2026'
date.format(d, 'MM/yyyy')
date.formatTimestamp(d)         // also accepts epoch millis: formatTimestamp(1773500000000)

Building a date from components

date.getDate(2026, 3, 15)                  // year, month (1-based), day
date.getDate(2026, 3, 15, 14, 30)          // + hour, minute
date.getDate(2026, 3, 15, 14, 30, 0)       // + second
date.copy(d)                               // defensive copy — java.util.Date is mutable

Current moment

date.currentDate()       // today, time part cleared
date.currentDatetime()   // now, including the time

date.current() still exists but is deprecated — use one of the two above.

Arithmetic. add* shifts, set* replaces the field, roll* changes the field without carrying into the next one:

date.addYear(d, 1)     date.addMonth(d, -3)    date.addDay(d, 30)
date.addHour(d, 2)     date.addMinute(d, 15)   date.addSecond(d, 30)
date.setYear(d, 2027)  date.setMonth(d, 6)     date.setDay(d, 1)
date.setHour(d, 12)    date.setMinute(d, 0)    date.setSecond(d, 0)
date.rollYear(d, 1)    date.rollMonth(d, 1)    date.rollDay(d, 1)
date.rollHour(d, 1)    date.rollMinute(d, 1)   date.rollSecond(d, 1)
date.addMonthOracle(d, 1)   // Oracle ADD_MONTHS semantics for end-of-month dates

Differences

date.getYearDiff(from, to)        date.getFullYearDiff(from, to)
date.getMonthDiff(from, to)       date.getDayDiff(from, to)
date.getDayDiff(from, to, 'yyyyMMdd')   // both operands as strings in that pattern
date.getYearsBetween(a, b)        date.getMonthsBetween(a, b)   date.getDaysBetween(a, b)
date.getAbsoluteYearDiff(a, b)

Field access

date.getYear(d)   date.getMonth(d)   date.getDay(d)
date.getHour(d)   date.getMinute(d)  date.getSecond(d)

A field getter returns 0 when the date cannot be parsed. date.getMonth is one-based, so a real month is 112 and 0 is not a valid answer — it is the signal that the input was not understood. date.getMonth('29-10-2018') returns 0, because a field getter accepts yyyy-MM-dd and yyyy-MM-dd HH:mm:ss only. Parse with date.parse first when the format is anything else.

Period boundaries and business days

date.getFirstDayOfMonth(d)    date.getLastDayOfMonth(d)
date.getFirstDayOfYear(d)     date.getLastDayOfYear(d)
date.getFirstDayOfYear(2026)  date.getFirstWorkingDayOfYear(2026)
date.isWorkDay(d)             date.isLastDayOfMonth(d)   date.isFirstWorkDayOfMonth(d)
date.getNextWorkDay(d)        date.getNearestWorkDay(d)
date.getPolishHolidays(2026)  date.getNearestWorkDayExcludingPolishHolidays(d)

Comparisons

date.isInPeriod(d, from, toExclusive)
date.isInPeriodInclusive(d, from, toInclusive)
date.compareYMD(a, b)         // ignores the time part
date.max(a, b)   date.min(a, b)        // three-argument forms exist too
date.trim(d)                  // drop the time part
date.isLeapYear(2026)   date.getDaysInYear(2026)
date.toInt(d)   date.fromInt(20260315)     // yyyyMMdd as an int
date.julianDay(d)   date.julianDayDiff(a, b)

Conversions — type#

type converts any value to a Higson type, regardless of what it started as — a cell from a decision table, a context value, or a plain Java object.

type.getString(o)     type.getBoolean(o)
type.getInteger(o)    type.getDecimal(o)     type.getDecimal(o, 2)
type.getNumber(o)     type.getNumber(o, 2)
type.getDate(o)       type.getDatetime(o)
type.getLocalDate(o)  type.getLocalDateTime(o)

type.getNumber(null) returns 0.0 and type.getBoolean(null) returns false — the same substitution the primitive getters make elsewhere. Use type.getDecimal when a missing value must stay missing.

Value Holders#

A holder is a value together with its Higson type, before conversion. It is what the engine passes around internally, and it is the one place where “the value is missing” and “the value is zero” stay distinguishable.

type.toNumberHolder(o)      type.toIntegerHolder(o)
type.toStringHolder(o)      type.toBooleanHolder(o)
type.toDateHolder(o)        type.toDatetimeHolder(o)
type.toLocalDateHolder(o)   type.toLocalDateTimeHolder(o)

Every holder answers the same questions:

Group Methods On a missing value
presence isNull(), isNotNull(), isBlank(), isComparable()
raw getValue(), getString() null
object getters getInteger(), getLong(), getDouble(), getBoolean(), getBigDecimal(), getDate(), getDatetime(), getLocalDate(), getLocalDateTime() null
primitive getters intValue(), longValue(), doubleValue(), booleanValue() 0 / false

That last pair of rows is the whole point:

def holder = type.toIntegerHolder(cell)

holder.intValue()      // 0    — cannot tell "absent" from "zero"
holder.getInteger()    // null — absent stays absent
holder.isNull()        // true

Holders appear at three entry points, all with the same surface: type.to*Holder(value) here, ctx.get*Holder(path) in Reading and Writing the Context, and matrix.getHolder(...) in Working with Decision Table Results.

Building a result matrix. A function can return a matrix of its own, which the caller then reads exactly like a decision table result:

return type.paramValue()
    .withCodes('code', 'premium')
    .withTypes('string', 'number')      // required, one per column
    .addValues('BI', 1200,
               'AC', 800)
    .create()

withCodes names the columns. addValues appends cells; withValues replaces everything accumulated so far — reach for addValues when building in a loop, or the earlier rows vanish. Either way the cell count must be a whole multiple of the column count, otherwise the build fails with an incomplete-matrix error. withMatrix(grid) supplies the whole Object[][] in one call, which pairs with util.matrix(rows, cols). withTypes(...) is required, not optional: it pins each column’s Higson type and must have exactly as many entries as withCodes. Without it, addValues throws. Close with create() for a matrix in the form functions read, or build() for the engine-level value.

For a single row there is type.multiValue(), with the same shape:

def row = type.multiValue()
    .withCodes('code', 'premium')
    .withTypes('string', 'number')
    .withValues('BI', 1200)
    .build()

Lower-level equivalents: type.toMultiValue(row, codes), type.toParamValue(rows), and type.wrap(paramValue) to put an engine-level value back into the form functions work with.

Domain Elements — domain#

domain reaches into Domain Configuration: the tree of elements (products, plans, coverages) and their attributes.

def root     = domain.get('MOTOR', ctx)   // profile root; drop ctx for static attributes only
def element  = domain.get('MOTOR', '/PLANS[FULL]/COVERAGES[BI]/', ctx)
def profiles = domain.getProfiles()

Navigating from an element

element.get('/COVERAGES[BI]/')          // throws when the path does not exist
element.getSafe('/COVERAGES[BI]/')      // null instead of throwing
element.get('COVERAGES', 'BI')          // by child type and code
element.getSafe('COVERAGES', 'BI')
element.getAll('COVERAGES')             // every child of that type
element.parent()

Reading attributes

element.getAttrString('name')
element.getAttrDecimal('basePremium', ctx)
element.getAttrNumber('factor')
element.getAttrInteger('maxAge')
element.getAttrDate('validFrom')
element.getAttrBoolean('active')
element.getAttrValue('tariff', ctx)     // a matrix, when the attribute is backed by a table

Pass ctx whenever the attribute is dynamic — that is, sourced from a decision table, a function or a flow. Without a context those attributes cannot be resolved.

Attribute handles. getAttrX resolves the attribute afresh on every call. When the same attribute is read more than once, or read in two different types, take a handle:

def premium = element.attr('basePremium')        // null when the attribute is undefined
premium.getDecimal(ctx)
premium.getString(ctx)
premium.getValue(ctx)                            // a matrix, when backed by a table

element.staticAttr('code')      // resolve against the static set only
element.dynamicAttr('premium')  // resolve against the dynamic set only

attr checks the static set first and falls back to the dynamic one, so it is the right default; the explicit forms matter when a name exists in both.

Checking before reading

element.hasAttr('discount')        element.isAttrDefined('discount')
element.hasStaticAttr('discount')  element.isAttrSet('discount')
element.hasDynamicAttr('discount')

Identity

element.code()      element.name()       element.getPath()
element.getTypeCode()   element.getTypeName()   element.isRoot()

domain is registered only when the domain cache is enabled. On a runtime configured without it the object is absent and any reference fails. If a function needs domain data, confirm the runtime is configured for it.

A misspelled attribute code fails silently. Reading an attribute that does not exist returns null / 0.0 / false rather than raising — the element substitutes an empty attribute. Guard with hasAttr(code) when the code comes from data rather than from a literal.

Traps#

  • A date field getter answers 0 for an unparseable input. getMonth is one-based (112), so 0 means “could not read the date”, not January. Field getters accept yyyy-MM-dd and yyyy-MM-dd HH:mm:ss only.
  • domain.get(path) throws when the path is missing; getSafe(path) returns null. Pick deliberately — the throwing form is right when a missing element is a configuration error, the safe form when it is an expected case.
  • Dynamic domain attributes need ctx. Reading them without a context cannot resolve the table, function or flow behind the attribute.
  • util.eq is not ==. It coerces types and treats null as a value that is below everything else. That is usually what you want when comparing table cells, and rarely what you want when comparing object identity.
  • math.round and type.getNumber return doubles. For money, stay in BigDecimal via _dec(x, n) or getDecimal.
  • type.getNumber(null) is 0.0, type.getBoolean(null) is false. No error is raised, so a missing value quietly becomes a real one.
  • A misspelled domain attribute code returns null/0.0/false, not an error.
  • str.format uses slf4j {} anchors, not %s. A %-pattern comes back unchanged.
  • str.trunc, str.split and str.contains are not null-safe, unlike the rest of str.
  • padLeft appends and padRight prepends — they name the alignment, not the pad side.
  • util.eq compares two numeric-looking strings numerically, so '10' equals '10.0'.
  • calculateHaversineDistance returns meters.
  • math.round(v, scale) is not HALF_UP and returns a double — not for money.
  • date.parseLong parses yyyy-MM-dd HH:mm:ss, it does not read epoch millis.
  • type.paramValue() requires withTypes as well as withCodes.
  • util here is not the $u Util Functions of Domain Configuration.
  • Some date methods are deprecated: current(), getHourDiff, getMinuteDiff, getTime(Calendar), subtractYear, and anything named *Deprecated.

Example#

// renewalPremium — recalculates a premium for a renewal date

def policyEnd = date.parse(ctx.getString('policy.endDate'))
def renewalOn = date.addDay(policyEnd, 1)

if (!date.isWorkDay(renewalOn)) {
    renewalOn = date.getNearestWorkDayExcludingPolishHolidays(renewalOn)
}

def age = date.getYearDiff(ctx.getDate('policy.holder.birthDate'), renewalOn)

def coverage = domain.get('MOTOR', '/PLANS[FULL]/COVERAGES[BI]/', ctx)
def base = coverage.getAttrDecimal('basePremium', ctx)

if (base == null) {
    log.warn("No basePremium on {} — cannot renew", coverage.getPath())
    return null
}

def loading = util.gt(age, 70) ? _dec('1.25') : _dec('1.00')
def premium = base * loading

log.info("Renewal {} on {} (age {}, loading {})",
    premium, date.format(renewalOn), age, loading)

return util.map(
    'renewalDate', date.format(renewalOn),
    'premium', _dec(premium, 2)
)