Generate language bindings for annotated C++ types straight from C++26 reflection — no external code generator, no parsing step.
welder is a header-only C++26 library that reads P2996 reflection and
P3394 annotations at compile time to emit binding-registration code for
your types (e.g. pybind11 class_<T> calls) directly, through template
instantiation. You mark a type with attributes saying which languages it should be
exposed to and which members participate; welder reflects over it and lays the
bindings down. On top of that it carries the reflected documentation into the target
language — Python .pyi stubs and Lua LuaCATS stubs — so IDE autocompletion and
static analysis come along for free.
Status: early proof-of-concept. Verified end-to-end (an importable Python module; a
require-able Lua module), but the API is still moving and gcc-16 is the only compiler that implements the papers it needs. Targets C++26 and newer only. Pre-1.0, a 0.x minor release may change the API (a 0.x patch only fixes); 1.0.0 comes once the API settles and a second compiler ships P2996.
welder emits through a rod — a small policy struct for one binding backend. The same annotated type binds through any rod you weld it for:
| Language | Backend | Output |
|---|---|---|
| Python | pybind11 | extension module + .pyi typing stubs |
| Python | nanobind | extension module + .pyi typing stubs |
| Python | trampolines | build-time .hpp of virtual-override trampolines (serves both Python rods) |
| Lua | sol2 | loadable module |
| Lua | LuaBridge3 | loadable module |
| Lua | LuaCATS | build-time ---@meta stub file |
Adding a language is one rod struct; the language-agnostic core is reused verbatim,
and the language identity space is open (welder::user_lang), so an out-of-tree rod
can bind a language welder doesn't ship.
Annotate the C++ type — say which languages, which members, and the docs — then let welder lay the bindings down:
#include <welder/vocabulary.hpp> // annotation vocabulary (header-only)
#include <pybind11/pybind11.h>
#include <welder/rods/python/pybind11/rod.hpp>
#include <welder/rods/python/naming.hpp> // welder::rods::python::pep8
struct [[=welder::weld(welder::lang::py, welder::lang::lua), // expose to py + lua
=welder::policy::automatic, // reflect all members
=welder::doc("An axis-aligned rectangle.")]]
Rectangle {
double width{0.0};
double height{0.0};
[[=welder::mark::exclude]] std::uint64_t cacheHandle{0}; // internal, bound nowhere
[[=welder::doc(R"(
Compute the area of the rectangle.
Width and height are treated as unsigned extents; a
degenerate (zero) side yields zero area.)"),
=welder::returns("The area in square units.")]]
double computeArea() const { return width * height; }
};
PYBIND11_MODULE(shapes, m) {
using welder::rods::pybind11::rod;
using welder::rods::python::google_style; // Google-style docstrings (Args:/Returns:)
using welder::rods::python::pep8; // PEP 8 names: computeArea → compute_area
welder::welder<rod<google_style>, pep8>::weld_type<Rectangle>(m);
}The compiled module carries the renamed method, the folded docstring, and not the excluded member:
>>> import shapes
>>> r = shapes.Rectangle()
>>> r.width, r.height = 3.0, 4.0
>>> r.compute_area() # C++ computeArea(), renamed to PEP 8
12.0
>>> print(shapes.Rectangle.compute_area.__doc__)
compute_area(self: shapes.Rectangle) -> float
Compute the area of the rectangle.
Width and height are treated as unsigned extents; a
degenerate (zero) side yields zero area.
Returns:
The area in square units.
>>> hasattr(r, "cacheHandle") # excluded member — never bound
FalseThe same annotated type binds to every language you weld it for — you write it once.
- No codegen step. The bindings are the compile. welder reads reflection +
annotations in-process — no
.ifiles, no generator to run, no parser to keep in sync with your headers. - A tiny vocabulary.
weld,policy,mark,doc,returns,tparam,weld_as. Say what binds and to which languages; welder resolves the rest at compile time. - One annotation, several audiences. A
docbecomes the Python__doc__, the Lua LuaCATS stub, and — via a Doxygen filter — the C++ reference. Write it once. - Idiomatic names per language. An injectable name-style transformer coerces
your C++ house style into the target's convention (e.g. PEP 8 for Python), with a
weld_asescape hatch for the cases a rule can't capture. - Fail-safe by contract. Every surface welder is about to bind must be representable — otherwise a hard compile error naming the offending type, never a silent skip.
welder removes boilerplate; it is not a universal binding abstraction. It does
not convert your types for you (that stays the framework's job — a pybind11
type_caster, a nanobind caster, a sol2 usertype), it does not replace the
binding framework, and it does not flatten the languages into one
lowest-common-denominator API.
- gcc-16 only today — the sole compiler implementing P2996 + P3394. The C++20
import welder;wrapper is deferred until the gcc-16 reflection/modules conflicts are fixed (why). - Member operators bind; free (non-member) operators don't yet.
- Properties (getter/setter pairs) are designed-for, not yet implemented.
- No per-function ownership / return-value-policy control yet — exclude such a member and hand-bind it beside welder (welder composes with manual binding code).
- Backend-inherited gaps: nanobind is single-base-only; LuaBridge3 rejects virtual base classes (use sol2 for virtual diamonds); LuaCATS stubs can't type comparison/subscript metamethods (they still work at runtime).
Planned next, roughly in order: properties, free operators, richer ownership/lifetime annotations, and the module wrapper once toolchains allow. Further language rods are where the architecture invites community contributions — adding a language is one rod struct.
Building welder's own examples and tests from a clone uses Conan 2 to provision the backends (pybind11 / nanobind / sol2). It also requires gcc-16 (GCC ≥ 16.1 — the only compiler with P2996 + P3394, installed from whatever package manager or source build you prefer) and CMake ≥ 3.28 + Ninja. (Consuming welder in your project needs neither Conan nor the backends — see Consuming welder.)
conan install . -pr:a conan/profiles/gcc16 --build=missing
cmake --preset welder-gcc16
cmake --build --preset welder-gcc16The example modules are then loadable, both built from the same welder core:
# Python
PYTHONPATH=build/welder-gcc16/examples/python_poc \
python3 -c "import welder_poc as w; p=w.Point(); p.x=1.5; print(p.x)"
# Lua
LUA_CPATH='build/welder-gcc16/examples/lua_poc/?.so' \
lua -e 'local s=require("shapes_lua"); local r=s.Rect(3,4); print(r:area())'See the getting-started guide for the full walkthrough.
welder is header-only and exports the core only — welder::headers is just the
include path. You bring your own backend (pybind11 / nanobind / sol2 /
LuaBridge3) and, on your own target, set C++26 + gcc's -freflection. welder does
not force the standard or the flag onto your target; it checks them and fails
with a clear message if they're missing, rather than imposing them.
CMake — FetchContent (no install step; as a subproject welder builds nothing of its own — no backends, tests or install rules — so it only needs a C++26 compiler):
include(FetchContent)
FetchContent_Declare(welder
GIT_REPOSITORY https://github.com/skarndev/welder.git
GIT_TAG main)
FetchContent_MakeAvailable(welder) # defines welder::headersCMake — CPM.cmake (CPM wraps FetchContent, so this is the same subproject collapse with caching/version conveniences on top):
CPMAddPackage("gh:skarndev/welder#main") # defines welder::headersOnce tagged releases exist, CPMAddPackage("gh:skarndev/welder@0.1.0") pins one
(CPM resolves the version to the v0.1.0 tag).
CMake — install + find_package (nothing of welder's own compiles, so disable the
dev-time build and install just the header tree):
cmake -S welder -B build \
-DWELDER_BUILD_EXAMPLES=OFF -DWELDER_BUILD_TESTS=OFF \
-DWELDER_BUILD_PYBIND11=OFF -DWELDER_BUILD_NANOBIND=OFF \
-DWELDER_BUILD_SOL2=OFF -DWELDER_BUILD_LUABRIDGE=OFF
cmake --install build --prefix /your/prefixConan (optional — only if your project already uses Conan). welder ships a recipe;
build and publish it to your local cache, then requires("welder/0.1.0") downstream:
conan create . -pr:a conan/profiles/gcc16 --build=missing # → local ~/.conan2 cacheGitHub Packages doesn't host Conan, so there's no public remote yet — the local cache is the current channel.
However you obtain welder, the target wiring is the same — here linking nanobind for a Python extension:
find_package(welder REQUIRED) # welder::headers + build helpers
find_package(Python 3.12 REQUIRED COMPONENTS Interpreter Development.Module)
find_package(nanobind CONFIG REQUIRED) # your backend, however you provide it
nanobind_add_module(mymod src/bindings.cpp) # your extension module
target_link_libraries(mymod PRIVATE welder::headers) # welder = the include path
target_compile_features(mymod PRIVATE cxx_std_26) # welder needs C++26 …
target_compile_options(mymod PRIVATE -freflection) # … + gcc-16's reflection flagfind_package(welder) (and a FetchContent pull) also define the build helpers —
welder_pybind11_generate_stubs, welder_sol2_add_module, … — for emitting the
loadable module and its stubs. (With FetchContent, drop the find_package(welder)
line — welder::headers is already defined by FetchContent_MakeAvailable.)
The full documentation lives at skarndev.github.io/welder — a mkdocs-material guide plus a Doxygen-generated C++ reference, rebuilt and published on every push. Highlights:
- Getting started · Annotation vocabulary
- Binding a type · Enums · Inheritance · Namespaces & modules
- Docstrings · Naming conventions
- The bindability gate · Trust & type casters
- Architecture · C++ API reference
MIT © 2026 Sergey Shumakov