Skip to content

Data Types

Kevin Herron edited this page Oct 5, 2026 · 10 revisions

Milo 1.2.0-SNAPSHOT, Java 17. API baseline 7faaf4f0516c.

This page is the lookup for how OPC UA values map to Java in Milo. Use it when you choose a Java value to write, interpret a value you read, or handle a server's custom structures. An OPC UA value carries a declared type and shape, and sometimes a quality and timestamp envelope. Your Java value must match all three, because the wire carries the type along with the number, so a numerically similar Java type is not interchangeable. See Getting started for dependencies and OPC UA concepts for NodeId and namespace identity.

Built-in mappings and unsigned values

Use this table to pick the Java type for a value you write or expect from a read.

OPC UA type Typical Milo or Java representation
Boolean Boolean
SByte, Int16, Int32, Int64 Byte, Short, Integer, Long
Byte, UInt16, UInt32, UInt64 UByte, UShort, UInteger, ULong
Float, Double Float, Double
String, Guid String, UUID
DateTime DateTime
ByteString, XmlElement ByteString, XmlElement
NodeId, ExpandedNodeId, QualifiedName, LocalizedText The correspondingly named Milo types
StatusCode, DataValue, Variant The correspondingly named Milo wrappers
A structured value in a Variant Usually an ExtensionObject, decoded using a matching context
Enumeration on the generic wire path An Int32 value. Typed codecs can expose enum classes. JSON VERBOSE uses named enum strings in structure fields.

In the tutorial thermostat, Temperature and Setpoint are Double Variables, so a read returns a Java Double and a write to Setpoint needs a Double Variant such as new Variant(22.5) or Variant.ofDouble(22.5).

Unsigned values

Java has no unsigned integer types, so Milo has its own classes for them. The factories in org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned handle a negative or oversized input in one of two ways:

Factory Behavior
ubyte(byte), ushort(short), uint(int), ulong(long) Reinterpret the supplied bits, so uint(-1) is 4,294,967,295
The wider numeric and text overloads, such as uint(long) and ulong(BigInteger) Convert the number and reject out-of-range magnitudes with NumberFormatException

To read the number back, use longValue() for a UInt32 and toBigInteger() for a UInt64, because ULong.longValue() returns the raw signed bits.

How Variant chooses a wire type

Variant infers its wire type from the Java value. new Variant(42) is Int32, new Variant(42L) is Int64, and new Variant(22.5f) is Float, so the literal matters. Writing new Variant(22.5f) or new Variant(22) to the tutorial Setpoint returns Bad_TypeMismatch even though the number fits, because the server checks the value's type against the Variable's Double DataType. A Java byte[] is likewise an SByte array, so use ByteString.of(bytes) for a ByteString.

The Variant constructor does not validate its argument. Prefer the typed factories such as ofDouble, ofUInt32, ofStruct, and ofMatrix, or Variant.of(...), which rejects unsupported classes, scalar nested Variants, DiagnosticInfo, and multidimensional Java arrays. An array of Variants is allowed, but a Variant directly containing another Variant is not.

Arrays, Matrix, and DataValue

Read this section when a value is an array or Matrix, or when you need to tell bad, empty, and null values apart.

Array shape and Matrix

A one-dimensional array and a scalar have different ValueRanks, even when the array holds one element. A Matrix holds a multidimensional value as flattened elements, dimensions, and element-type metadata. Prefer a nested-array factory such as Matrix.ofUInt32(UInteger[][]) when you can, so the dimensions come from the nested arrays.

With a flat constructor, your code must make the element count equal the product of the dimensions. The constructors check basic shape and type only with Java assertions and do not check that product, so Binary Variant encoding can write a count mismatch that fails only at decode time, with Bad_DecodingError.

This fragment builds a 2-by-2 UInt32 matrix with a flat constructor and wraps it in a DataValue. It uses uint(...) from Unsigned and Instant from java.time:

Matrix grid =
    new Matrix(
        new UInteger[] {uint(1), uint(2), uint(3), uint(4_000_000_000L)},
        new int[] {2, 2},
        OpcUaDataType.UInt32);
DataValue sample =
    new DataValue(
        new Variant(grid),
        StatusCode.GOOD,
        new DateTime(Instant.parse("2026-01-01T00:00:00Z")));

The matrix ends with the UInt32 value 4,000,000,000, and a round trip through the current JSON mapping preserves that magnitude, the dimensions, and both timestamps. This three-argument DataValue constructor sets both the source time and the server time to the supplied value. Use DataValue.valueOnly(...) for a value with no timestamps, or DataValue.newValue() to set them independently.

Decoded array classes

The array class you get back depends on the encoding that decoded it, so code that reads arrays should not assume one class:

Element type After Binary decoding After JSON decoding
Boolean, SByte, Int16, Int32, Int64, Float, Double Boxed array, such as Integer[] Primitive array, such as int[]
Unsigned and object types Object array Object array

The rule covers Variant arrays and flattened Matrix elements alike, so the same Int32 matrix has Integer[] elements after Binary decoding and int[] after JSON decoding. Inspect Matrix.getElementType() or access elements with java.lang.reflect.Array. Milo 1.1.7 behaves the same way.

Null, empty, and bad values

Java null, a null Matrix, an empty array, a null or empty ByteString, an absent optional member, and a bad DataValue with no usable value are distinct states. Code that treats them as one loses information.

Matrix.ofNull() represents a null Matrix, which isNull() detects. In 1.2, Binary Matrix field decoders return that object where 1.1 returned Java null. Inside a Binary Variant, Matrix.ofNull() instead becomes a null Variant whose Java payload is null. Encoding an empty Matrix inside a Variant can also change its dimensions, as described in encoding boundaries.

Check DataValue.statusCode() before you use the payload, because a successful service response can still contain a DataValue with bad quality. For example, a Read of the tutorial Temperature returns a Good DataValue holding 21.5, while a Read of an unknown NodeId in the same request returns a DataValue with Bad_NodeIdUnknown and no usable value.

Discover and decode a custom structure

Use this section when a server exposes structured values that your application has no Java class for. The client reads each structure's DataTypeDefinition from the server and builds codecs at runtime, so you can decode the value and inspect its fields by name. If you have a Java class for the type, register a codec instead.

The client keeps this metadata in a type tree and a dynamic data type manager built from it, and client.getDynamicEncodingContext() returns a context backed by that manager. The type-tree and dynamic-manager factories both default to eager discovery, so the first getDynamicEncodingContext() call builds the tree and the codecs with network I/O. Lazy factories instead resolve custom metadata on demand. Configure the factories before discovery starts. Discovery runs over the Session, so it must succeed within the Session's access and operation limits. A failed lazy resolution stays cached until you call clearFailedResolutions() on the lazy manager or the next Session reset.

Every Session establishment resets the client's type tree, dynamic manager, and dynamic context, so fetch the dynamic context again after reconnecting. Decoded values you retained from before still refer to their previous metadata.

This helper reads and decodes a structured Variable. It takes a connected OpcUaClient and a Variable NodeId resolved from the server's namespace URI, the way Your first client resolves Temperature:

static UaStructuredType readStructure(OpcUaClient client, NodeId variableId) throws UaException {
  DataValue value = client.readValue(0, TimestampsToReturn.Both, variableId);
  if (!value.statusCode().isGood()) {
    throw new UaException(value.statusCode());
  }
  if (!(value.value().value() instanceof ExtensionObject encoded)) {
    throw new IllegalArgumentException("Expected a structured value");
  }
  return encoded.decode(client.getDynamicEncodingContext());
}

For a structure discovered at runtime, the helper returns a DynamicStructType. Its getMembers() map holds the decoded fields by name, and a Matrix field keeps its dimensions. Check the actual structure type before you use the fields, instead of casting every result to DynamicStructType.

When the read or decode fails, you see one of these results:

Problem Result
The Variable does not exist UaException with Bad_NodeIdUnknown, before any decoding
The value is not a structure, for example a scalar Variable The helper's IllegalArgumentException
No codec is known for the type Unchecked UaSerializationException, typically Bad_DecodingError
The JSON or XML encoding module is missing Bad_EncodingError with "encoding not registered"
Any other decode failure Unchecked UaSerializationException

The namespace table, definitions, encoding IDs, and codecs must all describe the same model generation. ExtensionObject.decode(...) caches its first successful result per object, regardless of later context or encoding arguments, so a second call cannot switch a dynamically decoded value to an application class. Read a fresh ExtensionObject after changing registrations or contexts, and handle retained values explicitly across model changes.

Optional fields, unions, and subtypes

These rules apply when you build or inspect DynamicStructType and DynamicUnionType values, for example to write a discovered structure back to a server.

Optional fields

DynamicStructType.getMembers() is keyed by declared field names. For a StructureWithOptionalFields, the presence of a key, not its value, selects an optional field:

Map entry for an optional field Result
Key absent The field is omitted
Key present with a value The field is selected and encoded
Key present with a null value The field is selected and null is passed to its encoder, which works only for field types that accept null

Because of the last row, test for absence with containsKey(...), not map.get(...) == null.

Field names

Milo does not check field names. An unknown key is ignored, a typo in an optional field's name omits that field, and a typo in a required field's name supplies null for it. Build keys from getDataTypeDefinition().getFields(), and keep the required fields and the declared scalar and array ranks consistent.

Unions

DynamicUnionType carries at most one UnionValue(fieldName, fieldValue). getValue() returns the selection and is(fieldName) tests it.

Union state Meaning
Null UnionValue No member is selected
UnionValue for a nullable member with a null payload That member is selected, with a null value

An unknown member name fails encoding, and an invalid switch value on the wire fails decoding.

Subtype-enabled fields

A subtype-enabled field must identify the actual structured type of its value. Dynamic codecs can return UaStructuredType[] for subtype-enabled structure arrays and for ExtensionObject arrays inside Variant fields, so do not cast those arrays to one concrete dynamic type. Null ExtensionObjects in those arrays become Java null. If an ExtensionObject in a BaseDataType or Variant field fails to decode, the dynamic codec keeps the raw ExtensionObject value or array and logs at debug level. Preserve the array shape independently of each element's subtype.

Register a codec

Use this section when your application has a stable Java class for a structure and wants to read and write that class instead of a DynamicStructType.

Start with a GenericDataTypeCodec<T>. Implement getType(), encodeType(...), and decodeType(...) with the same field order and names on both sides, and with matching scalar, array, enum, Matrix, and structure calls. The value class's UaStructuredType identity methods must match the server's metadata.

Register the codec under the DataType ID and every available encoding ID, resolved against the actual namespace table, because a namespace index means something only in that table:

client
    .getStaticDataTypeManager()
    .registerType(dataTypeId, codec, binaryEncodingId, xmlEncodingId, jsonEncodingId);

Here codec is your codec, and the IDs are NodeIds built from the server's namespace URI. To get generated or application Java classes back from a read, decode with client.getStaticEncodingContext(), which uses these registrations.

registerType overwrites the existing type lookup and the lookups for the encoding IDs you supply, including built-in registrations. A null or OPC UA null encoding ID leaves that existing mapping unchanged. The registration is local to your process. It does not publish a DataType node to the server or make an incompatible peer understand the model.

For a temporary registration, use acquireType(...) instead. It rejects collisions with IllegalStateException and returns a RegistrationHandle, which you keep while the codec is needed and then close. A custom manager that does not support this method throws UnsupportedOperationException.

On a server, publish the DataType, its definition, the type hierarchy, and the encoding relationships, then register the corresponding codec with the server's static manager. ExampleNamespace.registerCustomStructType shows a server registration.

Two formats need extra care in a codec. XML namespace mappings for inherited fields come from XmlDataTypeCodec and the encoding context's getXmlNamespaceUris(), and JSON optional-field and union headers need the dedicated encoding-mask and switch-field APIs. Encodings describes both.

Legacy dictionaries

You need this section only when a server exposes its custom types through the older DataTypeDictionary mechanism alone, without DataTypeDefinition attributes.

A client reading from such a server needs the legacy initializer in milo-dtd-reader. Configure DataTypeManagerFactory.eager(new LegacyDataTypeManagerInitializer(client)) through setDynamicDataTypeManagerFactory(...) before discovery, as the linked legacy example does. This is an explicit alternative to the default DataTypeDefinition mechanism, so the client never switches to it on its own, and it does not guarantee support for every vendor dictionary.

That example targets an external Unified Automation demo server and a vendor-specific Variable, so its hardcoded address and namespace indexes are not local tutorial defaults. Supply your own reachable server, trust and identity configuration, model, and resolved NodeIds, then check dictionary parsing, reads, and writes against that server.

The legacy dictionary reader, manager, and corresponding dtd-core packages have been deprecated for removal at package level since 1.0.0, and the GDS dictionary package since 1.2.0. Package-level deprecation does not produce normal compiler warnings. Prefer DataTypeDefinition-based designs for new models.

Ownership

These rules decide where codec registrations live and who releases what.

Scope each codec registry to the client or server whose tables it uses. DefaultEncodingContext starts with only namespace 0 and returns the JVM-wide OpcUaDataTypeManager.getInstance() singleton. Do not register one application's model in that shared singleton.

Keep I/O out of encode and decode callbacks. Discover metadata beforehand, or let the configured client resolver own it. Disconnect the clients you own, and dispose of application buffers and writers at the end of their lifetimes.

Encoding happens before a Write is sent, so if it fails, the call performs no server write. A successful encode only means the value serialized, and the server still decides whether to accept it, so check the Write result.

Examples and reference

Next steps

These values reach the wire through an encoding, and each encoding has its own rules for nulls, shapes, and identifiers. Continue with Encodings when you store or exchange values outside an OPC TCP connection.

Related: Reference · Reading · Writing · Client nodes · Server data access

Clone this wiki locally