Xentropic Labs

Building zopcua: A Type-Safe OPC UA Library for Zig

When I started building ProtoNexus, my industrial data broker, I knew I’d need solid OPC UA support. OPC UA (Open Platform Communications Unified Architecture) is the de facto standard for industrial communication—if you’re building industrial IoT, SCADA systems, or data broker software for manufacturing, you can’t avoid it. The problem? There were no Zig bindings for OPC UA.

I could have used one of the existing C OPC UA libraries directly, but that would mean dealing with C’s conventions throughout my codebase. That’s not ideal when you’re trying to build reliable, maintainable industrial software in Zig. So I built zopcua, a type-safe Zig wrapper around open62541, the most popular open-source OPC UA implementation.

The Problem with C-based OPC UA Libraries

Let me show you what working with UA_Variant looks like in the C OPC UA API from open62541. A Variant in OPC UA is a discriminated union type that can hold different kinds of industrial data—integers, floats, strings, arrays, timestamps, you name it. Here’s the C struct from open62541:

typedef struct {
    const UA_DataType *type;      /* The data type description */
    UA_VariantStorageType storageType;
    size_t arrayLength;           /* The number of elements in the data array */
    void *data;                   /* Points to the scalar or array data */
    size_t arrayDimensionsSize;   /* The number of dimensions */
    UA_UInt32 *arrayDimensions;   /* The length of each dimension */
} UA_Variant;

To work with OPC UA Variants in C, you need to:

This works, but it’s error-prone. You’re constantly checking pointers, casting void pointers, and hoping you got the type right. There’s no compile-time type safety, no exhaustive switch checking, and lots of opportunities for runtime errors in your OPC UA client or server.

The Zig Solution: Tagged Unions for OPC UA

In zopcua, I transformed the C OPC UA Variant into a Zig tagged union:

pub const Variant = union(enum) {
    empty: void,

    // Scalar OPC UA types
    boolean: bool,
    sbyte: i8,
    byte: u8,
    int16: i16,
    uint16: u16,
    int32: i32,
    uint32: u32,
    int64: i64,
    uint64: u64,
    float: f32,
    double: f64,
    string: []const u8,
    date_time: i64,
    guid: Guid,
    byte_string: []const u8,
    node_id: NodeId,
    status_code: u32,
    localized_text: LocalizedText,

    // Array types for PLC data
    boolean_array: []const bool,
    sbyte_array: []const i8,
    byte_array: []const u8,
    int32_array: []const i32,
    double_array: []const f64,
    string_array: []const []const u8,
    // ... and more

    // For edge cases not covered above
    raw: c.UA_Variant,
};

Now the Zig compiler knows exactly what OPC UA type you’re working with. You get:

Creating OPC UA Values: Simple and Type-Safe

Here’s what creating an OPC UA Variant looks like in zopcua:

// Explicitly specify the OPC UA type (i32)
const variant = ua.Variant.scalar(i32, 42);

// Array of OPC UA Double values
const values = [_]f64{ 1.1, 2.2, 3.3 };
const variant = ua.Variant.array(f64, &values);

The scalar function uses a comptime type parameter to determine which OPC UA type to create. No magic—just Zig’s compile-time capabilities making the API clean.

Compare this to the C OPC UA version where you’d be calling UA_Variant_setScalarCopy with type descriptors and checking status codes:

UA_Variant variant;
UA_Int32 value = 42;
UA_StatusCode status = UA_Variant_setScalarCopy(
    &variant,
    &value,
    &UA_TYPES[UA_TYPES_INT32]
);
if (status != UA_STATUSCODE_GOOD) {
    // handle error
}

Converting Between Zig and C OPC UA Representations

The real work happens in the toC() and fromC() functions. When you need to pass a Variant to the underlying open62541 OPC UA library, zopcua handles all the conversion:

pub fn toC(self: Variant, allocator: std.mem.Allocator) !c.UA_Variant {
    var result: c.UA_Variant = undefined;

    switch (self) {
        .empty => {
            c.UA_Variant_init(&result);
        },
        .int32 => |val| {
            const status = helpers.helper_variant_setScalarCopy(
                &result,
                &val,
                &c.UA_TYPES[c.UA_TYPES_INT32]
            );
            if (status != c.UA_STATUSCODE_GOOD) return error.VariantInitFailed;
        },
        .int32_array => |values| {
            const int32_type = &c.UA_TYPES[c.UA_TYPES_INT32];
            const status = helpers.helper_variant_setArrayCopy(
                &result,
                values.ptr,
                values.len,
                int32_type
            );
            if (status != c.UA_STATUSCODE_GOOD) return error.VariantInitFailed;
        },
        // ... handle all other OPC UA types
    }

    return result;
}

The switch is exhaustive—if I add a new OPC UA variant type and forget to handle it in toC(), the Zig compiler won’t let me build. This kind of compile-time safety is what I mean when I say industrial software should be reliable and resilient.

Going the other direction is similar. When you read a value from an OPC UA server, zopcua converts the C representation back into a type-safe Zig tagged union:

pub fn fromC(value: c.UA_Variant, allocator: std.mem.Allocator) !Variant {
    if (value.type == null or value.data == null) return .empty;

    const type_index = getTypeIndex(value.type);
    const is_array = value.arrayLength > 0;

    if (is_array) {
        return fromCArray(value, type_index, allocator);
    } else {
        return fromCScalar(value, type_index, allocator);
    }
}

Memory management is explicit. When you call fromC(), zopcua deep-copies the OPC UA data so you own it. When you’re done, call deinit():

const value = try client.readValueAttribute(node_id, allocator);
defer value.deinit(allocator);  // Clean up OPC UA data when done

No hidden allocations. No garbage collector. Just explicit ownership that the Zig compiler can verify.

What This Looks Like in Practice: OPC UA Client Example

Here’s a complete example of writing a value to an OPC UA server using zopcua:

const ua = @import("ua");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    // Connect to OPC UA server
    const client = try ua.Client.init();
    defer client.deinit();

    try client.connect("opc.tcp://localhost:4840");
    defer client.disconnect() catch {};

    const node_id = ua.NodeId.initString(1, "the.answer");

    // Read current value from OPC UA server
    const current = try client.readValueAttribute(node_id, allocator);
    defer current.deinit(allocator);
    std.log.info("Current value: {}", .{current});

    // Write new value to OPC UA server
    const new_value = ua.Variant.scalar(i32, 42);
    try client.writeValueAttribute(node_id, new_value);

    // Confirm the write
    const confirmed = try client.readValueAttribute(node_id, allocator);
    defer confirmed.deinit(allocator);
    std.log.info("New value: {}", .{confirmed});
}

Clean. Explicit. Type-safe. This is what I mean by functional and intuitive software. The OPC UA API guides you toward correct usage.

Why Zig for Industrial OPC UA Software

I’m building ProtoNexus in Zig because industrial software needs to be rock-solid. Production systems running OPC UA can’t afford memory leaks or undefined behavior. They need to run for months or years without restarting. When you’re processing real-time data from PLCs and SCADA systems running million-dollar production lines, reliability isn’t optional.

Zig’s philosophy aligns perfectly with industrial software requirements:

zopcua takes these Zig principles and applies them to OPC UA. Instead of fighting with void pointers and manual type checking in C, you get a clean API that leverages Zig’s type system to make wrong code hard to write.

Current Status of zopcua

zopcua is in active development. It’s not production-ready yet, but it’s functional enough to power the OPC UA support in ProtoNexus. The core OPC UA functionality works:

The library requires Zig 0.15.2 and statically links mbedTLS for OPC UA cryptographic operations and secure channels (you can use system mbedTLS if you prefer).

Try zopcua: OPC UA Examples for Zig

Check out the examples in the repo. They start simple and progressively show more OPC UA features:

The full API documentation is generated from the source and kept up to date.

If you’re building industrial software, SCADA systems, or IIoT applications in Zig, or if you just want to see how to wrap a complex C library idiomatically, take a look. Contributions are welcome—there’s plenty of OPC UA functionality left to wrap, and I’d love to see this library grow.

Building reliable industrial software means choosing the right tools and using them correctly. For me, that means Zig. And now, thanks to zopcua, it also means OPC UA done right.


Links: