Skip to main content
penscript provides a full set of operators for working with collections — iterating, transforming, filtering, querying, and building arrays. These are used to generate dynamic form fields, validate uploaded documents, compute aggregates, and build conditional structures based on variable-length data.

Context Variables

When iterating over an array (with :map, :every, :some, :find, or :filter), penscript makes the following context variables available inside the loop body:
VariableDescription
{@item}The current item value
{@index}Current index (0, 1, 2…)
{@position}Current position (1, 2, 3…)
{@first}true if the current item is the first
{@last}true if the current item is the last
Access nested properties of the current item with dot notation: {@item.name}, {@item.role}, {@item.amount}.

Normalizing to Arrays: :array

Wraps a value in an array if it isn’t one already. Useful for ensuring consistent array input when a value might be a single item or an array.
{ ":array": 1 }
// Result: [1]

{ ":array": "{data.tags}" }
// Scope: { "data": { "tags": [1, 2, 3] } }
// Result: [1, 2, 3]

{ ":array": "{data.tags}" }
// Scope: { "data": { "tags": "urgent" } }
// Result: ["urgent"]

Generating Arrays with :fill

Combine :array with :fill to generate arrays of a specific length. Each element is created from the :fill template, with context variables available.
{
  ":array": 3,
  ":fill": { "id": "item_{@index}" }
}

// Result: [{ "id": "item_0" }, { "id": "item_1" }, { "id": "item_2" }]

Mapping: :map

Transforms every item in an array. The transformation expression has access to {@item}, {@index}, {@position}, {@first}, and {@last}. Using :to syntax:
{
  ":map": ["a", "b", "c"],
  ":to": "Value: {@item} (pos: {@position})"
}

// Result: ["Value: a (pos: 1)", "Value: b (pos: 2)", "Value: c (pos: 3)"]
Using array syntax — the second element is the transformation expression:
{
  ":map": [
    "{data.numbers}",
    { ":sum": ["@item", "@item"] }
  ]
}

// Scope: { "data": { "numbers": [1, 2, 3] } }
// Result: [2, 4, 6]
Complex mapping with local variables using :with: This is the most powerful pattern — generating dynamic structures from data. The :with operator creates local variables for each iteration, keeping the template readable.
{
  ":map": [
    { ":range-array": [0, "{data.count}"] },
    {
      ":with": [
        { "pos": { ":sum": ["{@index}", 1] } },
        [
          { "type": "paragraph", "title": "Person {pos}" },
          {
            "key": "name_{@index}",
            "type": "text",
            "title": "Person {pos} name"
          }
        ]
      ]
    }
  ]
}

// Scope: { "data": { "count": 2 } }
// Result:
// [
//   { "type": "paragraph", "title": "Person 1" },
//   { "key": "name_0", "type": "text", "title": "Person 1 name" },
//   { "type": "paragraph", "title": "Person 2" },
//   { "key": "name_1", "type": "text", "title": "Person 2 name" }
// ]

Filtering: :filter

Removes items from an array based on a condition. Works by mapping items to themselves (kept) or false (removed), then stripping the falsy values.
{
  ":filter": {
    ":map": [
      "{data.numbers}",
      {
        ":if": [
          { ":cmp": "@item", ":gt": 2 },
          "@item",
          false
        ]
      }
    ]
  }
}

// Scope: { "data": { "numbers": [1, 2, 3, 4, 5] } }
// Result: [3, 4, 5]
Filtering with inline expressions:
{
  ":filter": {
    ":map": [
      "{data.emails}",
      {
        ":if": [
          "{{ @item.endsWith('@example.com') }}",
          "@item",
          false
        ]
      }
    ]
  }
}

// Scope: { "data": { "emails": ["[email protected]", "[email protected]", "[email protected]"] } }
// Result: ["[email protected]", "[email protected]"]

Finding Items: :find

Returns the first item in an array that matches a condition. If no match is found, returns undefined.
{
  ":find": ["{data.users}", { ":eq": ["@item.role", "admin"] }]
}

// Scope: {
//   "data": {
//     "users": [
//       { "id": 1, "role": "user" },
//       { "id": 2, "role": "admin" },
//       { "id": 3, "role": "admin" }
//     ]
//   }
// }
// Result: { "id": 2, "role": "admin" }   (first match only)

Membership & Intersections

:includes

Tests if an array contains a specific value.
{ ":includes": [[1, 2, 3], 2] }
// Result: true

{ ":includes": [[1, 2, 3], 4] }
// Result: false
Works with :if for access control patterns:
{
  ":if": { ":includes": ["{data.roles}", "admin"] },
  ":then": "Full access",
  ":else": "Limited access"
}

// Scope: { "data": { "roles": ["user", "admin"] } }
// Result: "Full access"

:intersects

Tests if two arrays share any common elements.
{ ":intersects": ["{data.user_tags}", ["vip", "premium"]] }

// Scope: { "data": { "user_tags": ["new", "vip"] } }
// Result: true

// Scope: { "data": { "user_tags": ["basic"] } }
// Result: false

Counting: :count

Returns the number of items in an array.
{ ":count": "{data.items}" }

// Scope: { "data": { "items": [1, 2, 3] } }
// Result: 3

Conditional Counting with :where

Count only items that satisfy a condition:
{
  ":count": "{data.items}",
  ":where": { ":cmp": "@item", ":gt": 1 }
}

// Scope: { "data": { "items": [1, 2, 3, 4] } }
// Result: 3   (counts 2, 3, 4)

Excluding Items with :unless

Combine :where and :unless for fine-grained filtering:
{
  ":count": "{data.items}",
  ":where": { ":cmp": "@item", ":gt": 1 },
  ":unless": { ":eq": ["@item", 3] }
}

// Scope: { "data": { "items": [1, 2, 3, 4] } }
// Result: 2   (counts 2 and 4 — excludes 3)

Range Arrays: :range-array

Generates an array of sequential integers. The start is inclusive, the end is exclusive.
{ ":range-array": [0, 3] }
// Result: [0, 1, 2]

{ ":range-array": [2, 6] }
// Result: [2, 3, 4, 5]
Commonly used with :map to generate a dynamic number of form fields or UI elements based on a user-provided count.

Flattening: :flatten

Deeply flattens nested arrays into a single flat array.
{ ":flatten": [[1, [2, [3]]]] }
// Result: [1, 2, 3]

{
  ":flatten": ["{data.items}", [null, 4, [[5], [[[[6]]]]]], false]
}

// Scope: { "data": { "items": [1, 2, 3] } }
// Result: [1, 2, 3, null, 4, 5, 6, false]
Useful after a :map that produces arrays of arrays — for example, when each mapped item generates multiple form fields and you need them in a single flat list.

Sequence Check: :increasing

Returns true if all values in the array are in strictly increasing order.
{ ":increasing": "{data.daily_balances}" }

// Scope: { "data": { "daily_balances": [100, 150, 200] } }
// Result: true

// Scope: { "data": { "daily_balances": [100, 90, 120] } }
// Result: false
Useful for validating that sequential inputs are in the expected order (e.g., dates, amounts, installment schedules).

Next Steps