Zig / C++ Interop

Johnny Marler

Johnny Marler · @johnnymarler · November 11, 2025

Note: this blog post is cross-posted from my personal blog

I’ve been writing Zig and C++ that have to talk to each other. I want both languages to be able to store data types from the other in their own structs/classes.

const c = @cImport({ @cInclude("cppstuff.h"); });
const MyZigType = struct {
    foo: c.SharedPtrFoo,
};
#include <zigstuff.h>
class MyCppType {
    ZigFoo foo;
};

Keep in mind, I don’t want to just define all my Zig types as extern types. I want to use existing types from the standard library and be able to embed those inside my C++ types. I don’t want my choice of programming language to limit where I can put things in memory.

When you want to embed a type, you need its definition, but you don’t actually need the full definition. You just need the size/alignment. That tells the compiler where to offset it into the container type and how much space to reserve for it. In addition, both Zig and C++ can verify type sizes and alignment at compile time. So, we can replace needing the full type definition with a simple macro that provides an opaque type with the same size/alignment, and have the “home language” for that type verify it’s correct at compile time. Here’s such a macro:

#define SIZED_OPAQUE(name, size, align)                  \
    typedef struct {                                     \
        _Alignas(align) unsigned char _[size];           \
    } __attribute__((aligned(align))) name;              \
    enum { name##Size = size, name##Align = align }

// Allows Zig to include fields that store a shared_ptr<Foo>
SIZED_OPAQUE(SharedPtrFoo, 8, 8)

// Allows C++ to include fields that store a zig-native Foo struct
SIZED_OPAQUE(ZigFoo, 120, 4)

And both sides can verify the sizes at compile-time like this:

const c = @cImport({@cInclude("thestuff.h")});
comptime {
    if (@sizeOf(ZigFoo) != c.ZigFooSize) {
        @compileError(std.fmt.comptimePrint("define ZigFoo size as: {}", .{@sizeOf(ZigFoo)}));
    }
    if (@alignOf(ZigFoo) != c.ZigFooAlign) {
        @compileError(std.fmt.comptimePrint("define ZigFoo align as: {}", .{@alignOf(ZigFoo)}));
    }
}
static_assert(sizeof(SharedPtrFoo) == sizeof(std::shared_ptr<Foo>));
static_assert(alignof(SharedPtrFoo) == alignof(std::shared_ptr<Foo>));

One case where I’m using this is to store an instance of Zig’s std.http.Client in a C++ class. The size of that changes depending on the optimization mode, which looks like this:

#if defined(ZIG_OPTIMIZE_DEBUG)
    SIZED_OPAQUE(HttpZig, 81152, 8);
#elif defined(ZIG_OPTIMIZE_SMALL)
    SIZED_OPAQUE(HttpZig, 81136, 8);
#elif defined(ZIG_OPTIMIZE_SAFE)
    SIZED_OPAQUE(HttpZig, 81144, 8);
#else
    #error ZIG_OPTIMIZE_ not defined
#endif

Maybe at some point I’ll make a build step that generates this info, but this is a simple starting point. Plus, it’s nice to see the actual sizes and get notified when they change.

Once you have the types, how do you use them? The short answer is pointers. For example, if you want to pass a shared_ptr to Zig, you need to pass a pointer to the shared pointer, like this:

export fn takeMyString(from_cpp: *c.SharedPtrStdString) void {
    // DON'T do this: you can't just copy a shared ptr without asking C++ for permission first
    // const bad_boi: c.SharedPtrStdString = from_cpp.*;

    // DO this: define methods to play with your C++ types
    var my_string: c.SharedPtrStdString = undefined;
    c.shared_ptr_std_string_move(&my_string, from_cpp); // Use C++ function to move

    const data: [*]const u8 = c.shared_ptr_std_string_data(&my_string);
    const size: usize = c.shared_ptr_std_string_size(&my_string);
    std.log.info("the string is '{s}'", .{data[0..size]});

    giveBackToCpp(&my_string);
}
extern fn giveBackToCpp(s: *const c.SharedPtrStdString) void;

Notice that we’ve defined any function we need from C++ to move types around or access data. On the C++ side you’ll need a bunch of casting. However, I recently found a new pattern I quite like. In C++, if a type is defined by C++ then it’s “concrete,” otherwise it’s “opaque.” If you’re passing pointers to shared pointers and casting between them, you’ll find yourself with sore eyes from squinting too much, i.e.:

void screen_video_sink_remove(const SharedPtrScreenVideoSink* sink, const SharedPtrTnPeer* peer)
{
    const std::shared_ptr<ScreenVideoSink>& sink_cpp = *((std::shared_ptr<ScreenVideoSink>*)sink);
    const std::shared_ptr<tn::Peer>& peer_cpp = *(std::shared_ptr<tn::Peer>*)peer;
    peer_cpp->RemoveVideoSink(sink_cpp, tn::Peer::VideoSource::Screen);
}

The new pattern I’ve taken to is a macro that takes an opaque/concrete type pair and gives you the functions needed to convert them, i.e.

    // defines conversion functions between the "opaque types" used by Zig
    // and the concrete types in C++
    #define DEFINE_OPAQUE_CONCRETE(Opaque, Concrete) \
        Opaque* opaque(Concrete* c) { return reinterpret_cast<Opaque*>(c); } \
        const Opaque* opaque(const Concrete* c) { return reinterpret_cast<const Opaque*>(c); } \
        Concrete& concrete(Opaque* o) { return *reinterpret_cast<Concrete*>(o); } \
        const Concrete& concrete(const Opaque* o) { return *reinterpret_cast<const Concrete*>(o); }

    DEFINE_OPAQUE_CONCRETE(StdString, std::string)
    DEFINE_OPAQUE_CONCRETE(SharedPtrTnCall, std::shared_ptr<tn::Call>)
    DEFINE_OPAQUE_CONCRETE(SharedPtrTnPeer, std::shared_ptr<tn::Peer>)
    DEFINE_OPAQUE_CONCRETE(SharedPtrTnCallNotification, std::shared_ptr<tn::CallNotification>)
    DEFINE_OPAQUE_CONCRETE(ClientAuthSessionOpaque, tn::RestAPI::ClientAuthSession)
    DEFINE_OPAQUE_CONCRETE(TnCurrentUser, tn::RestAPI::CurrentUser)
    DEFINE_OPAQUE_CONCRETE(WebrtcVideoFrameBuffer, webrtc::VideoFrameBuffer)
    DEFINE_OPAQUE_CONCRETE(SharedPtrScreenVideoSink, std::shared_ptr<ScreenVideoSink>)

And our code above now becomes:

void screen_video_sink_remove(const SharedPtrScreenVideoSink* sink, const SharedPtrTnPeer* peer)
{
    concrete(peer)->RemoveVideoSink(concrete(sink), tn::Peer::VideoSource::Screen);
}

This is much improved as we’ve now pre-ordained the right conversions up-front and no longer have to re-audit the casts for every single conversion.