How to auto-detect and polyfill std::expected on GCC 11 using tl::expected

If you try to compile C++23 code that uses std::expected on an older toolchain, you may hit:

gcc_expected_error.txt
/home/uli/Tether/include/tether/io/TetherIOClient.hpp:33:10: fatal error: expected: No such file or directory
   33 | #include <expected>
      |          ^~~~~~~~~~
compilation terminated.

This happens because <expected> is only available in libstdc++ from GCC 12 (and then in a fully usable form from GCC 13). GCC 11, including the default compiler on Ubuntu 22.04 (GCC 11.4), ships no <expected> header at all — not even as <experimental/expected>.

The cleanest fix is to fall back to the tl::expected library: it is the reference implementation that std::expected (P0323) was based on, so a thin namespace shim makes existing #include <expected> / std::expected(...) / std::unexpected(...) call sites compile unchanged.

Add tl::expected as a submodule

add_expected_submodule.sh
git submodule add https://github.com/TartanLlama/expected.git dependencies/expected
cd dependencies/expected
git checkout v1.3.1
cd ../..
git add dependencies/expected .gitmodules

Create a std::expected shim header

Create include/yourproject/expected_shim/expected (the file is literally named expected so that #include <expected> resolves to it):

include/yourproject/expected_shim/expected
#pragma once
#include <tl/expected.hpp>
#include <type_traits>
#include <utility>

namespace std {
    using tl::expected;

    // NOTE: We cannot simply do `using tl::unexpected;` because GCC < 12's
    // <exception> header already declares `void std::unexpected()` — the
    // deprecated C++03 unexpected-handler function. A using-declaration cannot
    // override an existing function declaration in the same namespace, so
    // `std::unexpected(arg)` would resolve to the no-arg function and fail.
    //
    // Instead we define a function template that overloads it. When called with
    // an argument (the only way C++23 code uses std::unexpected), overload
    // resolution picks this template over the deprecated void unexpected().
    template <class E>
    auto unexpected(E&& e) {
        return tl::unexpected<std::decay_t<E>>(std::forward<E>(e));
    }
} // namespace std

Why not using tl::unexpected;?

GCC 11’s <exception> header declares the deprecated C++03 function void std::unexpected() (the unexpected-handler). A using tl::unexpected; declaration cannot override an existing function declaration in the same namespace, so std::unexpected(arg) resolves to the no-arg function and fails:

gcc_unexpected_conflict.txt
/usr/include/c++/11/exception:95:8: note: declared here
   95 |   void unexpected() __attribute__ ((__noreturn__));
      |        ^~~~~~~~~~
TetherIOClient.cpp:696:40: error: too many arguments to function 'void std::unexpected()'
  696 |     if (!result) return std::unexpected(result.error());
      |                         ~~~~~~~~~~~~~^~~~~~~~~~~~~~~~

The function template overloads the deprecated function — when called with an argument, overload resolution picks the template.

Auto-detect <expected> in CMake

Add this near the top of your CMakeLists.txt, after project() and after setting CMAKE_CXX_STANDARD:

CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(MyProject VERSION 1.0.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

include(CheckIncludeFileCXX)
set(CMAKE_REQUIRED_FLAGS "-std=c++23")
check_include_file_cxx("expected" HAVE_NATIVE_STD_EXPECTED)
unset(CMAKE_REQUIRED_FLAGS)

if(NOT HAVE_NATIVE_STD_EXPECTED)
    message(STATUS "Native <expected> not available; using tl::expected shim.")

    # Validate submodule
    set(EXPECTED_PATH "${CMAKE_CURRENT_SOURCE_DIR}/dependencies/expected")
    if(NOT EXISTS "${EXPECTED_PATH}/include/tl/expected.hpp")
        message(FATAL_ERROR
            "expected submodule missing. Run: git submodule update --init --recursive")
    endif()

    # Shim must be searched BEFORE system paths so #include <expected> finds it
    include_directories(BEFORE
        "${CMAKE_CURRENT_SOURCE_DIR}/include/yourproject/expected_shim")
    include_directories("${EXPECTED_PATH}/include")
else()
    message(STATUS "Native <expected> available; using it directly.")
endif()

Keep source code unchanged

Your existing code can continue to use the standard spelling:

example.cpp
#include <expected>
#include <iostream>
#include <string>

struct Error { int code; std::string msg; };

std::expected<int, Error> divide(int a, int b) {
    if (b == 0) {
        return std::unexpected(Error{1, "division by zero"});
    }
    return a / b;
}

int main() {
    auto result = divide(10, 2);
    if (result) {
        std::cout << *result << '\n';   // 5
    }

    auto err = divide(10, 0);
    if (!err) {
        std::cerr << err.error().msg << '\n';
    }
    return 0;
}

On GCC 15 this compiles with the native <expected>. On GCC 11 the same file compiles through the tl::expected shim.

What is covered by the shim

The patterns used in real code are all supported:

  • std::expected<T, E> — value-or-error wrapper
  • std::expected<void, E> — error-only specialization
  • std::unexpected(error) — factory relying on CTAD (works because tl::unexpected<E> has a deduction guide)
  • .value(), .error(), .has_value(), operator bool, operator*, operator->

The shim does not need to wrap monadic helpers (and_then, transform, etc.) unless your code uses them; tl::expected v1.3.1 exposes the older map / map_error names, so if you need those, use the standard names on GCC 12+ and keep the polyfill minimal.

How it works

include_directories(BEFORE ...) prepends the shim directory to the compiler’s include search path. When the compiler sees #include <expected>, it finds the shim header instead of the (missing) system header. The shim re-exports tl::expected’s expected and unexpected templates under namespace std, so the rest of the project never needs to know which implementation is in use.

tl::expected is header-only, so there is no extra library to link.

Summary

HeaderFallback submoduleNative sinceShim header
<expected>dependencies/expected (tl::)GCC 12expected_shim/expected

This keeps your project portable across GCC 11 through the latest release without sprinkling #ifdef guards or tl:: names through the code.


Check out similar posts by category: C/C++ C++23 CMake