#Maps, Arrays, and Records
XSLT 3.0 introduced maps and arrays as first-class data types, bringing key-value pairs and ordered collections into a language that previously only had XML nodes and atomic values. XSLT 4.0 adds records and xsl:for-each-member. If you come from C#, think of maps as Dictionary<string, object>, arrays as List<object>, and records as anonymous types or C# records.
#Contents
#xsl:map and xsl:map-entry
A map is an unordered collection of key-value pairs. Keys are atomic values (strings, numbers, dates); values can be anything — strings, numbers, nodes, sequences, other maps, or arrays.
C# parallel: Dictionary<string, object> or an anonymous object new { Name = "Alice", Age = 30 }.
#XPath Literal vs. xsl:map
You can construct maps in pure XPath using the map{} literal syntax:
<!-- XPath map literal — keys and values are expressions -->
<xsl:variable name="colors" select="map {
'error': 'red',
'warning': 'orange',
'info': 'blue'
}"/>The xsl:map instruction does the same thing, but allows dynamic construction — the entries can be computed with XSLT instructions:
<!-- xsl:map — dynamic construction -->
<xsl:variable name="colors" as="map(xs:string, xs:string)">
<xsl:map>
<xsl:map-entry key="'error'" select="'red'"/>
<xsl:map-entry key="'warning'" select="'orange'"/>
<xsl:map-entry key="'info'" select="'blue'"/>
</xsl:map>
</xsl:variable>#When to Use xsl:map Over XPath Literals
Use xsl:map when you need to:
-
Build entries conditionally
-
Generate entries from iteration
-
Include complex computed values
<!-- Building a map from XML data -->
<xsl:variable name="config" as="map(*)">
<xsl:map>
<!-- Static entries -->
<xsl:map-entry key="'version'" select="'2.1'"/>
<!-- Conditional entry -->
<xsl:if test="$debug-mode">
<xsl:map-entry key="'debug'" select="true()"/>
</xsl:if>
<!-- Entries from iteration -->
<xsl:for-each select="//setting">
<xsl:map-entry key="string(@name)" select="string(.)"/>
</xsl:for-each>
</xsl:map>
</xsl:variable>C# parallel:
var config = new Dictionary<string, object>
{
["version"] = "2.1"
};
if (debugMode)
config["debug"] = true;
foreach (var setting in settings)
config[setting.Name] = setting.Value;#Accessing Map Values
Use the XPath ? lookup operator or the map:get() function:
<!-- Lookup operator (preferred — concise) -->
<xsl:value-of select="$colors?error"/> <!-- "red" -->
<!-- map:get function (equivalent) -->
<xsl:value-of select="map:get($colors, 'error')"/> <!-- "red" -->
<!-- Dynamic key lookup -->
<xsl:variable name="level" select="@level"/>
<span style="color: {$colors($level)}">
<xsl:value-of select="@message"/>
</span>
<!-- Check if key exists -->
<xsl:if test="map:contains($config, 'debug')">
<p>Debug mode is active.</p>
</xsl:if>#Useful Map Functions
|
Function |
Description |
C# Equivalent |
|---|---|---|
|
|
Get value by key |
|
|
|
Check if key exists |
|
|
|
All keys as a sequence |
|
|
|
Number of entries |
|
|
|
New map with added/replaced entry |
Immutable — returns new dict |
|
|
New map without the key |
Immutable — returns new dict |
|
|
Merge multiple maps |
Multiple |
|
|
Apply function to each entry |
|
|
|
Deep search for key in nested maps |
No direct equivalent |
#Nested Maps
Maps can contain other maps, building hierarchical data structures:
<xsl:variable name="api-response" as="map(*)">
<xsl:map>
<xsl:map-entry key="'status'" select="200"/>
<xsl:map-entry key="'data'">
<xsl:map>
<xsl:map-entry key="'user'">
<xsl:map>
<xsl:map-entry key="'id'" select="$user-id"/>
<xsl:map-entry key="'name'" select="$user-name"/>
<xsl:map-entry key="'email'" select="$user-email"/>
</xsl:map>
</xsl:map-entry>
</xsl:map>
</xsl:map-entry>
</xsl:map>
</xsl:variable>
<!-- Access nested values with chained ? -->
<xsl:value-of select="$api-response?data?user?name"/>#xsl:array and xsl:array-member
An array is an ordered collection of values. Unlike sequences, arrays can contain other arrays and maps as individual members, and members can themselves be sequences.
C# parallel: List<object> or object[].
#XPath Literal vs. xsl:array
XPath provides square-bracket syntax for array literals:
<!-- XPath array literal -->
<xsl:variable name="primes" select="[2, 3, 5, 7, 11, 13]"/>
<!-- Nested arrays -->
<xsl:variable name="matrix" select="[[1, 2, 3], [4, 5, 6], [7, 8, 9]]"/>The xsl:array instruction allows dynamic construction:
<!-- xsl:array — dynamic construction -->
<xsl:variable name="product-names" as="array(xs:string)">
<xsl:array>
<xsl:for-each select="//product">
<xsl:array-member select="string(name)"/>
</xsl:for-each>
</xsl:array>
</xsl:variable>#Arrays vs. Sequences
This distinction trips up many newcomers. Both hold ordered collections, but they behave differently:
|
Sequence |
Array |
|
|---|---|---|
|
Nesting |
Sequences flatten automatically: |
Arrays preserve nesting: |
|
Members |
Atomic values and nodes only |
Any XDM value, including arrays, maps, and sequences |
|
Empty |
|
|
|
Usage |
Default for most XPath operations |
Required for JSON arrays and structured data |
C# parallel: Sequences are like flattened IEnumerable<T>, while arrays are like List<object> that can contain nested lists.
#Accessing Array Members
<!-- By position (1-based) -->
<xsl:value-of select="$primes(3)"/> <!-- 5 -->
<!-- Using the ? lookup operator -->
<xsl:value-of select="$primes?3"/> <!-- 5 -->
<!-- All members as a sequence (the * wildcard) -->
<xsl:value-of select="$primes?*" separator=", "/> <!-- 2, 3, 5, 7, 11, 13 -->#Useful Array Functions
|
Function |
Description |
C# Equivalent |
|---|---|---|
|
|
Number of members |
|
|
|
Get member by position |
|
|
|
New array with replaced member |
Immutable update |
|
|
New array with added member |
|
|
|
Slice the array |
|
|
|
New array without the member |
|
|
|
First member |
|
|
|
All members except the first |
|
|
|
Concatenate arrays |
|
|
|
Flatten nested arrays to a sequence |
|
|
|
Apply function to each member |
|
|
|
Keep members matching predicate |
|
#Building an Array from XML
<!-- Convert product elements to an array of maps -->
<xsl:variable name="products-array" as="array(map(*))">
<xsl:array>
<xsl:for-each select="//product">
<xsl:array-member>
<xsl:map>
<xsl:map-entry key="'name'" select="string(name)"/>
<xsl:map-entry key="'price'" select="number(price)"/>
<xsl:map-entry key="'category'" select="string(@category)"/>
</xsl:map>
</xsl:array-member>
</xsl:for-each>
</xsl:array>
</xsl:variable>
<!-- Access: second product's name -->
<xsl:value-of select="$products-array(2)?name"/>#xsl:for-each-member (XSLT 4.0)
xsl:for-each-member iterates over the members of an array, binding each member to a variable. This is the array counterpart to xsl:for-each (which iterates over sequences).
C# parallel: foreach (var item in array) { ... }
#Basic Usage
<xsl:variable name="tags" select="['xslt', 'xml', 'transformation']"/>
<ul>
<xsl:for-each-member select="$tags" as="member">
<li><xsl:value-of select="$member"/></li>
</xsl:for-each-member>
</ul>Output:
<ul>
<li>xslt</li>
<li>xml</li>
<li>transformation</li>
</ul>#Why Not Just Use xsl:for-each?
When array members are themselves sequences or complex values, xsl:for-each with array:flatten() would lose the member boundaries. xsl:for-each-member preserves each member as a distinct unit:
<!-- An array where each member is a sequence of names -->
<xsl:variable name="teams" select="[('Alice', 'Bob'), ('Carol', 'Dave', 'Eve')]"/>
<!-- for-each-member preserves the grouping -->
<xsl:for-each-member select="$teams" as="team">
<div class="team">
<xsl:for-each select="$team">
<span><xsl:value-of select="."/></span>
</xsl:for-each>
</div>
</xsl:for-each-member>Output:
<div class="team">
<span>Alice</span>
<span>Bob</span>
</div>
<div class="team">
<span>Carol</span>
<span>Dave</span>
<span>Eve</span>
</div>#Iterating Over Maps in an Array
A common pattern when working with JSON-like data:
<xsl:variable name="users" select="[
map { 'name': 'Alice', 'role': 'admin' },
map { 'name': 'Bob', 'role': 'editor' },
map { 'name': 'Carol', 'role': 'viewer' }
]"/>
<table>
<thead><tr><th>Name</th><th>Role</th></tr></thead>
<tbody>
<xsl:for-each-member select="$users" as="user">
<tr>
<td><xsl:value-of select="$user?name"/></td>
<td><xsl:value-of select="$user?role"/></td>
</tr>
</xsl:for-each-member>
</tbody>
</table>#xsl:record (XSLT 4.0)
xsl:record constructs a map with named entries, similar to a C# anonymous type or record. It is syntactic sugar for xsl:map with xsl:map-entry, but with a cleaner, more readable syntax.
C# parallel: Anonymous types new { Name = "Alice", Age = 30 } or records record User(string Name, int Age).
#Basic Usage
<xsl:variable name="user" as="map(*)">
<xsl:record>
<name><xsl:value-of select="@name"/></name>
<age select="xs:integer(@age)"/>
<email><xsl:value-of select="email"/></email>
</xsl:record>
</xsl:variable>
<!-- Access like a map -->
<xsl:value-of select="$user?name"/>Each child element of xsl:record becomes a map entry where:
-
The element name becomes the string key
-
The element's content (or
selectattribute) becomes the value
#Comparison: xsl:record vs. xsl:map
The following are equivalent:
<!-- Using xsl:record (cleaner) -->
<xsl:record>
<name>Alice</name>
<age select="30"/>
<active select="true()"/>
</xsl:record>
<!-- Using xsl:map (more verbose) -->
<xsl:map>
<xsl:map-entry key="'name'" select="'Alice'"/>
<xsl:map-entry key="'age'" select="30"/>
<xsl:map-entry key="'active'" select="true()"/>
</xsl:map>Use xsl:record when all keys are fixed, known string names. Use xsl:map when keys are dynamic or non-string.
#Building Records from XML
<xsl:template match="employee">
<xsl:variable name="emp" as="map(*)">
<xsl:record>
<id select="string(@id)"/>
<fullName>
<xsl:value-of select="concat(firstName, ' ', lastName)"/>
</fullName>
<department select="string(@dept)"/>
<salary select="xs:decimal(salary)"/>
<isManager select="@role = 'manager'"/>
</xsl:record>
</xsl:variable>
<!-- Use the record -->
<div class="employee-card">
<h3><xsl:value-of select="$emp?fullName"/></h3>
<p>Department: <xsl:value-of select="$emp?department"/></p>
<xsl:if test="$emp?isManager">
<span class="badge">Manager</span>
</xsl:if>
</div>
</xsl:template>#JSON Output Patterns
Maps and arrays are the bridge between XML and JSON in XSLT 3.0+. When you serialize a map or array with method="json", it produces JSON output directly.
#XML to JSON Conversion
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="3.0">
<xsl:output method="json" indent="yes"/>
<xsl:template match="/">
<!-- The template result is a map — serialized as JSON -->
<xsl:map>
<xsl:map-entry key="'catalog'">
<xsl:array>
<xsl:for-each select="//product">
<xsl:array-member>
<xsl:map>
<xsl:map-entry key="'name'" select="string(name)"/>
<xsl:map-entry key="'price'" select="number(price)"/>
<xsl:map-entry key="'category'" select="string(@category)"/>
<xsl:map-entry key="'inStock'" select="stock > 0"/>
<xsl:if test="description">
<xsl:map-entry key="'description'" select="string(description)"/>
</xsl:if>
</xsl:map>
</xsl:array-member>
</xsl:for-each>
</xsl:array>
</xsl:map-entry>
</xsl:map>
</xsl:template>
</xsl:stylesheet>Output:
{
"catalog": [
{
"name": "Laptop",
"price": 999.99,
"category": "electronics",
"inStock": true,
"description": "High-performance laptop"
},
{
"name": "T-Shirt",
"price": 19.99,
"category": "clothing",
"inStock": true
}
]
}C# parallel:
var catalog = new {
catalog = products.Select(p => new {
name = p.Name,
price = p.Price,
category = p.Category,
inStock = p.Stock > 0,
description = p.Description // null omitted by some serializers
})
};
string json = JsonSerializer.Serialize(catalog);#API Response Construction
A common pattern for building REST API responses:
<xsl:template name="api-response">
<xsl:param name="status" as="xs:integer"/>
<xsl:param name="data" as="item()*"/>
<xsl:param name="message" as="xs:string" select="''"/>
<xsl:map>
<xsl:map-entry key="'status'" select="$status"/>
<xsl:map-entry key="'success'" select="$status ge 200 and $status lt 300"/>
<xsl:if test="$message != ''">
<xsl:map-entry key="'message'" select="$message"/>
</xsl:if>
<xsl:map-entry key="'data'" select="$data"/>
<xsl:map-entry key="'timestamp'" select="string(current-dateTime())"/>
</xsl:map>
</xsl:template>#Reading JSON Input
XSLT 3.0 can also read JSON into maps and arrays using json-doc() or parse-json():
<!-- Load a JSON configuration file -->
<xsl:variable name="config" select="json-doc('config.json')"/>
<!-- Access values -->
<xsl:value-of select="$config?database?host"/>
<xsl:value-of select="$config?database?port"/>
<!-- Iterate over array members -->
<xsl:for-each select="$config?allowedOrigins?*">
<origin><xsl:value-of select="."/></origin>
</xsl:for-each>#Use Cases
#Building Configuration from XML
<xsl:variable name="app-config" as="map(*)">
<xsl:map>
<xsl:for-each select="configuration/appSettings/add">
<xsl:map-entry key="string(@key)" select="string(@value)"/>
</xsl:for-each>
</xsl:map>
</xsl:variable>
<!-- Usage -->
<connection-string>
<xsl:value-of select="$app-config?connectionString"/>
</connection-string>#Lookup Tables
Maps make excellent lookup tables, replacing verbose xsl:choose chains:
<!-- Instead of a 50-line xsl:choose -->
<xsl:variable name="country-names" select="map {
'US': 'United States',
'GB': 'United Kingdom',
'DE': 'Germany',
'FR': 'France',
'JP': 'Japan'
}"/>
<!-- Fast O(1) lookup -->
<xsl:value-of select="$country-names(@country-code)"/>C# parallel:
var countryNames = new Dictionary<string, string>
{
["US"] = "United States",
["GB"] = "United Kingdom",
["DE"] = "Germany",
["FR"] = "France",
["JP"] = "Japan"
};
var name = countryNames[countryCode];#Data Transformation Pipeline
Combine maps and arrays to transform XML into structured data for further processing:
<!-- Step 1: Transform XML to structured data -->
<xsl:variable name="order-data" as="map(*)">
<xsl:map>
<xsl:map-entry key="'orderId'" select="string(@id)"/>
<xsl:map-entry key="'items'">
<xsl:array>
<xsl:for-each select="item">
<xsl:array-member>
<xsl:map>
<xsl:map-entry key="'sku'" select="string(@sku)"/>
<xsl:map-entry key="'quantity'" select="xs:integer(@qty)"/>
<xsl:map-entry key="'unitPrice'" select="xs:decimal(@price)"/>
<xsl:map-entry key="'lineTotal'"
select="xs:decimal(@qty) * xs:decimal(@price)"/>
</xsl:map>
</xsl:array-member>
</xsl:for-each>
</xsl:array>
</xsl:map-entry>
<xsl:map-entry key="'total'"
select="sum(item/(xs:decimal(@qty) * xs:decimal(@price)))"/>
</xsl:map>
</xsl:variable>
<!-- Step 2: Use the structured data -->
<invoice>
<order-id><xsl:value-of select="$order-data?orderId"/></order-id>
<total><xsl:value-of select="format-number($order-data?total, '$#,##0.00')"/></total>
</invoice>