Skip to content

Custom Transforms

The 46 built-in transforms cover strings, math, lists, and date-times, but sooner or later a template needs something app-specific — a currency formatter, a lookup against your own enum, a domain-specific string tweak. Writing one is the most common way to extend kBars beyond what Dokka's generated API reference shows on its own; this page works through it end to end.

The KbTransform interface

A transform is a functional interface:

public fun interface KbTransform {
  public fun KbTransformScope.transform(input: KbTransformable?, arguments: List<KbTransformable?>): KbTransformable?
}
  • input is the result of the prior step in the pipe chain (null if nothing precedes it, or if the prior step yielded nothing).
  • arguments are the transform's own :-separated arguments (see Arguments), already resolved to values — empty when the template didn't pass any.
  • The lambda runs with KbTransformScope as its receiver, which is where the coercion and construction helpers below come from.
  • The return value becomes the input to the next step in the chain (or the rendered value, if this is the last step).

Registering one

Register transforms through the same configure DSL lambda every KbEnvironment factory function accepts — KbEnvironment.create, KbEnvironment.fromJson, KbEnvironment.fromYaml:

val env =
  KbEnvironment.create(context) {
    addTransform("reverse") { input, _ -> text(asString(input).reversed()) }
  }

val template = KbTemplate.create("{{ name | reverse }}")

println(template.render(env)) // context { "name": "alice" } -> "ecila"

addTransform throws IllegalStateException if the name is already registered — including the 46 built-ins. Use overrideTransform instead when you're deliberately replacing one.

Accepting arguments

Arguments arrive as List<KbTransformable?>, in template source order. Validate the count before indexing into it:

addTransform("shout") { input, args ->
  require(args.size <= 1) { "shout: takes at most 1 argument" }

  val punctuation = if (args.isEmpty()) "!" else asString(args[0])

  text(asString(input).uppercase() + punctuation)
}
{{ message | shout }}       {# "WATCH OUT!" #}
{{ message | shout: "?!" }} {# "WATCH OUT?!" #}

Reading and producing values

input and each entry in arguments are KbTransformable?, not raw strings — the same wrapper type that flows through the whole pipe chain. KbTransformScope supplies the coercion and construction functions used to move between it and Kotlin types:

Direction Function Notes
Read as String asString(value) Same rule {{ }} interpolation uses; null reads as "null".
Read as KpaElement? asElement(value) Unwraps to the underlying kPointer element (struct/list/primitive).
Read as KpaList? asList(value) null if value doesn't wrap a list.
Read as Int? asIntOrNull(value) Routes through asString then toIntOrNull.
Read as Double? asNumberOrNull(value) null if value isn't numeric (or a numeric-looking string).
Read as Instant? asDateTimeOrNull(value) null unless value is a KbTransformable.DateTime.
Produce a string result text(value: String)
Produce a numeric result number(value: Double)
Produce a boolean result bool(value: Boolean)
Produce a list result list(items: List<KpaElement>)
Produce a date-time result dateTime(value: Instant) Renders as an ISO-8601 UTC string, like @env/now.
Wrap an existing element element(value: KpaElement?) For when you already have a KpaElement from factory or elsewhere.

factory (the active KpaElementFactory) is also available on the scope directly, for building structs or lists from scratch rather than transforming an existing one — see the built-in list transforms for examples that do this.

Error handling inside a transform

There are two ways for a transform to signal a problem, and the built-ins use both, for different kinds of problem:

Soft-fail for problems with the data. Call softFail(category, problem, input) and return its result (always null) when the input or an argument is the wrong shape for what the transform does — a non-numeric string passed to a math transform, an unparseable date string, and so on. This is recoverable: the render continues, the expression evaluates to nothing, and (if the app registered a KbEventListener) the failure is reported as a structured KbSoftFailEvent instead of silently vanishing.

addTransform("half") { input, _ ->
  val n = asNumberOrNull(input) ?: return@addTransform softFail(
    KbSoftFailEvent.Category.TypeMismatch,
    "half: non-numeric input",
    input,
  )

  number(n / 2)
}

Throw for problems with the template itself. An argument-count mismatch, or any other error a template author could only fix by editing the template, is not data-dependent — it's the same every time this template renders. The built-in transforms throw IllegalStateException (typically via require/check) for exactly this case, and kBars's renderer catches IllegalArgumentException and IllegalStateException raised from inside a transform, attaches the failing expression's source location, and re-throws it as a KbRenderException from KbTemplate.render. Your custom transforms get the same treatment for free:

addTransform("nth") { input, args ->
  require(args.size == 1) { "nth: requires exactly 1 argument, got ${args.size}" }
  // ...
}
try {
  template.render(env)
} catch (e: KbRenderException) {
  // e.message includes "nth: requires exactly 1 argument, got 0" plus the template location
}

Any other exception type thrown from inside a transform propagates unwrapped — reserve that for genuine bugs in the transform's own implementation, not for expected input or template-author mistakes.

Overriding and removing built-ins

The same builder methods that add a new transform can replace or remove an existing one — including any of the 46 built-ins:

KbEnvironment.create(context) {
  overrideTransform("upcase") { input, _ -> text(asString(input).uppercase().replace('İ', 'I')) }
  removeTransform("downcase") // "downcase" is now an unknown-transform render error if referenced
}

overrideTransform never throws for an unknown name — it behaves like addTransform in that case. removeTransform is a no-op if the name isn't registered.

Where to next

  • Transforms — the | pipeline syntax and the full built-in catalog.
  • Environment Properties — registering @env properties with the same builder DSL, and the KbEventListener that observes soft failures.
  • API Reference — the full Dokka reference for KbTransform, KbTransformScope, and KbTransformable.