Part of our i3X and OPC UA series. New to i3X? Start with the practical introduction, then read how REST meets real-time.
Mapping OPC UA Data Types to JSON Schema
How (DataType, ValueRank, ArrayDimensions) becomes an i3X JSON Schema — with almost no ambiguity
TL;DR
- Every OPC UA variable describes its value shape with three orthogonal attributes:
DataType(what an element is),ValueRank(how many dimensions), andArrayDimensions(how long each dimension is).- i3X uses JSON Schema instead. The two models line up almost perfectly:
DataTypebecomes a schema leaf, eachValueRankdimension becomes one{"type":"array","items":…}wrapper, andArrayDimensionsbecomesminItems/maxItems.- This mapping is mechanical, total, and lossless for every case that fixes the rank — which is why OPC UA's triple is a strong concept: orthogonal concerns, closed domains, one binding invariant.
- The one genuinely ambiguous value,
ValueRank = -2(Any) — scalar or array or matrix — has a native JSON Schema image:oneOf. It is a faithful translation, not a workaround.- In node-i3x, Sterfive's OPC UA → i3X bridge, this is exactly how ObjectType schemas are generated.
When you put a REST/JSON layer on top of OPC UA, one question decides whether the result is clean or leaky:
How do you describe the shape and type of a value?
OPC UA and i3X answer it with completely different vocabularies — and the quality of the bridge depends entirely on how faithfully you translate one into the other. Here is how we reason about it in node-i3x, and why the two models fit together as well as they do.
Two ways to describe the same thing
OPC UA carries three attributes on every Variable and VariableType node (OPC UA Part 3, §5.6.2):
| Attribute | OPC UA type | Meaning |
|---|---|---|
DataType | NodeId | What a single element is — Double, Int32, String, a Structure… |
ValueRank | Int32 | How many dimensions the value has — scalar, 1-D array, matrix… |
ArrayDimensions | UInt32[] | How long each dimension is (0 = unknown / unbounded) |
i3X carries none of these. It expresses type with a single JSON Schema fragment (i3X RFC §5.2.2: "Type definitions … MUST be expressed as JSON Schema"). Everything about shape is folded into the recursive structure of the schema:
{ "type": "number" } // a scalar Double
{ "type": "array", "items": { "type": "number" } } // an array of Double
The engineering question is whether the OPC UA triple survives that translation intact. For the overwhelming majority of real nodes, it does.
The OPC UA side, precisely
DataType describes a single element, never the collection. That is the first reason the mapping is clean: DataType maps to a JSON Schema leaf and nothing else. It never influences nesting.
ValueRank has a small, closed set of meanings (OPC UA Part 3):
ValueRank | Symbolic name | Meaning |
|---|---|---|
n > 1 | — | array with exactly n dimensions (matrix / tensor) |
1 | OneDimension | a one-dimensional array |
0 | OneOrMoreDimensions | array of one or more dimensions |
-1 | Scalar | a scalar — not an array |
-2 | Any | scalar OR array of any rank |
-3 | ScalarOrOneDimension | scalar OR one-dimensional array |
The values ≥ 1 and -1 fix the rank exactly. The values 0, -2, -3 deliberately decline to — they say "the rank is decided at runtime." That distinction is the whole story.
ArrayDimensions is meaningful only when ValueRank ≥ 1, and when present its length equals ValueRank. Each entry is a dimension's length; 0 means "unknown." It is a refinement of ValueRank, never independent of it.
Why the triple is a strong concept
"Strong" means the three attributes are orthogonal, closed-domain, and jointly constrained, so no two consistent triples describe the same shape:
- Orthogonal concerns.
DataType= what an element is;ValueRank= how many dimensions;ArrayDimensions= how big. Change one, the others keep their meaning. - Closed domains.
ValueRankis a finite enumeration plus the single open rayn > 1. There is exactly one encoding for "scalar" and exactly one for "1-D array." No aliasing. - A binding invariant.
length(ArrayDimensions) == ValueRankties the two shape attributes together, so a shape can be validated rather than guessed. - Rank-independent element type. An N-dimensional array of
Doubleand a scalarDoubleshare the sameDataType. The element type is a stable, reusable atom.
These are exactly the properties JSON Schema needs to receive a mechanical, total translation: a leaf that is independent of how many array wrappers you stack around it.
The JSON Schema image
The correspondence is dimension-for-dimension:
DataType → the innermost leaf schema
ValueRank = k → k nested { type:"array", items:… } wrappers
ArrayDimensions → minItems/maxItems on each wrapper (0 ⇒ omit the bound)
That is the entire idea. The OPC UA triple and the JSON Schema tree are two encodings of the same type × rank × extent lattice.
How node-i3x implements it
The mapping lives in node-i3x's schema-builder. It has two halves.
DataType → leaf. A lookup normalizes both symbolic names ("Double") and numeric NodeIds ("i=11", "ns=0;i=11") onto a JSON primitive, honoring the RFC requirement that values coerce to JSON types:
OPC UA DataType | JSON Schema leaf |
|---|---|
Boolean | { "type": "boolean" } |
SByte…UInt64, Integer, Enumeration, StatusCode | { "type": "integer" } |
Float, Double, Number, Duration | { "type": "number" } |
String, LocalizedText, QualifiedName, Guid, NodeId | { "type": "string" } |
ByteString | { "type": "string", "contentEncoding": "base64" } |
DateTime, UtcTime | { "type": "string", "format": "date-time" } |
ExtensionObject / Structure | { "type": "object" } |
Two deliberate choices keep the leaf unambiguous for a JSON consumer: rich string-like types (LocalizedText, Guid, NodeId) collapse to string because that is what they are on the wire; ByteString carries contentEncoding: "base64" so binary stays lossless in a JSON-Schema-native way.
ValueRank → wrapping. Wrap once per dimension. For an element leaf E and ValueRank = k:
function wrapRank(leaf, valueRank, arrayDimensions) {
if (valueRank < 0) return leaf; // scalar (or handled by oneOf)
const dims = Math.max(valueRank, 1); // ValueRank 0 ⇒ at least 1-D
let schema = leaf;
for (let d = dims - 1; d >= 0; d--) {
const wrapper = { type: 'array', items: schema };
const len = arrayDimensions?.[d];
if (typeof len === 'number' && len > 0) { // 0 ⇒ unbounded ⇒ no bound
wrapper.minItems = len;
wrapper.maxItems = len;
}
schema = wrapper;
}
return schema;
}
A 2-D matrix of Double (ValueRank = 2, ArrayDimensions = [3,4]) becomes:
{
"type": "array", "minItems": 3, "maxItems": 3, // outer dimension
"items": {
"type": "array", "minItems": 4, "maxItems": 4, // inner dimension
"items": { "type": "number" } // the Double leaf
}
}
Because length(ArrayDimensions) == ValueRank, the nesting depth and the bounds agree by construction — which is precisely why the translation is total.
The special case: ValueRank = -2 (Any)
Every case above fixes the rank. ValueRank = -2 is the one value that refuses to: the variable may legitimately hold a scalar, a 1-D array, or a matrix at different times, all under the same DataType. This is not sloppy modeling — it is a first-class OPC UA statement of "the shape varies at runtime."
A single fixed JSON Schema leaf cannot describe this. {"type":"number"} rejects [1,2,3]; {"type":"array",…} rejects the scalar 42. Any binary "scalar-or-array" guess is wrong for one of the two runtime forms.
JSON Schema has a native construct for "any one of these alternative shapes": oneOf. It matches the open nature of Any exactly — a value is valid iff it matches exactly one branch, and the scalar / vector / matrix branches are mutually exclusive by JSON type:
// DataType = Double, ValueRank = -2 (Any) → Scalar OR Vector OR Matrix
{
"title": "Measurement",
"oneOf": [
{ "type": "number" }, // Scalar
{ "type": "array", "items": { "type": "number" } }, // 1-D array
{ "type": "array", // ≥2-D array
"items": { "type": "array", "items": { "type": "number" } } }
]
}
For ValueRank = -3 (ScalarOrOneDimension), keep just the first two branches.
The elegance is that oneOf inherits the same strengths that made the OPC UA triple strong:
- Orthogonality preserved — every branch shares the same element leaf, so the
DataTypedecision is still made once. Branches differ only in rank wrapping — exactly howValueRankis orthogonal toDataType. - Domain stays closed —
-2expands to a small, enumerable set of branches;-3to exactly two. No open-ended guessing. - No aliasing — the branches are mutually exclusive by JSON type, so a validator can tell you which rank a concrete value actually is.
In other words, oneOf is the structural JSON Schema image of ValueRank = Any. The OPC UA "rank decided at runtime" statement becomes the JSON Schema "matches exactly one of these ranks" statement — same semantics, native encoding, zero information lost.
Summary
OPC UA (DataType, ValueRank, ArrayDimensions) | JSON Schema |
|---|---|
Double, -1 (scalar) | {type:"number"} |
Double, 1, [0] (1-D, unbounded) | {type:"array", items:{type:"number"}} |
Double, 1, [3] (1-D, fixed) | … + minItems/maxItems: 3 |
Double, 2, [3,4] (matrix) | nested array × 2, with bounds |
Double, -2 (Any) | oneOf [scalar, 1-D, matrix] |
Double, -3 (scalar-or-1-D) | oneOf [scalar, 1-D] |
OPC UA's (DataType, ValueRank, ArrayDimensions) is a strong, orthogonal description of value shape, and JSON Schema expresses the same lattice structurally. The mapping is mechanical and lossless for every rank-fixing case; the one open construct, ValueRank = Any, translates faithfully to oneOf. That fidelity is what lets node-i3x hand IT developers a clean JSON Schema without throwing away what OT engineers modeled in OPC UA.
Frequently asked questions
What is ValueRank in OPC UA?
ValueRank is an Int32 attribute on every OPC UA Variable and VariableType that declares how many dimensions the value has. -1 means scalar, 1 means a one-dimensional array, n > 1 means an n-dimensional array (a matrix), and 0, -2, -3 are "open" ranks that are resolved against the runtime value.
What does ValueRank = -2 mean?
ValueRank = -2 is the symbolic value Any: the variable may hold a scalar or an array of any number of dimensions, and which one is decided at runtime. It is a deliberate statement that the shape varies, not a missing specification.
How do you represent an OPC UA array or matrix in JSON Schema?
Wrap the element's leaf schema in one {"type":"array","items":…} layer per dimension. A 1-D Double array is {"type":"array","items":{"type":"number"}}; a 2-D matrix nests a second array wrapper. ArrayDimensions map to minItems/maxItems on each wrapper (a dimension length of 0 means unbounded, so the bound is omitted).
Why use oneOf for ValueRank = -2?
Because a single fixed JSON Schema cannot accept both a scalar and an array. oneOf lets the value match exactly one of the scalar, vector, or matrix branches — mirroring OPC UA's "the rank is decided at runtime" semantics natively, with no information lost and no ambiguity for a validator.
Does node-i3x do this automatically?
Yes. node-i3x's schema builder generates a JSON Schema for each OPC UA ObjectType by mapping each member's DataType to a JSON leaf and wrapping it according to ValueRank, so i3X clients receive standards-compliant JSON Schema derived directly from the OPC UA information model.
At Sterfive we build node-i3x — the dual-licensed TypeScript bridge that turns any OPC UA address space into a clean i3X REST/JSON API, on top of the open-source node-opcua stack. Available under AGPL-3.0 or with a commercial license for proprietary deployments. Talk to us to get started.