Intro
When writing template code, a parameter pack is usually easy to expand but
annoying to index. If I have typename... Ts, expanding all of them is
straightforward:
std::tuple<Ts...>
However, selecting the Nth type from that pack has never felt like a language
operation. Before C++26, I usually need to route the pack through a library type:
template <std::size_t I, typename... Ts>
using nth_type_t = std::tuple_element_t<I, std::tuple<Ts...>>;
For values, the usual trick is similar:
template <std::size_t I, typename... Ts>
constexpr decltype(auto) nth_value(Ts&&... values) {
return std::get<I>(
std::forward_as_tuple(std::forward<Ts>(values)...)
);
}
This works, but the tuple is not the thing I want to model. The tuple is only a vehicle used to make a parameter pack indexable.
C++26 adds pack indexing, spelled as:
pack...[constant-expression]
For type packs, it yields a type. For expression packs, it yields an expression. So the two helpers above become:
template <std::size_t I, typename... Ts>
using nth_type_t = Ts...[I];
template <std::size_t I, typename... Ts>
constexpr decltype(auto) nth_value(Ts&&... values) {
return std::forward<Ts...[I]>(values...[I]);
}
The syntax looks strange at first, but after using it a few times, I think it is one of those small features that removes a surprising amount of template noise.
Selecting Multiple Elements
The basic nth_type_t example is useful, but it does not show the whole value
of the feature. A more interesting case is selecting several elements from a pack
and preserving the order requested by an index pack.
Suppose we have a tiny type-list:
namespace meta {
template <typename... Ts>
struct type_seq {};
template <std::size_t N, std::size_t... Is>
concept valid_indices = ((Is < N) && ...);
template <typename Seq, typename Indexes>
struct select;
template <typename... Ts, std::size_t... Is>
requires valid_indices<sizeof...(Ts), Is...>
struct select<type_seq<Ts...>, std::index_sequence<Is...>> {
using type = type_seq<Ts...[Is]...>;
};
template <typename Seq, typename Indexes>
using select_t = typename select<Seq, Indexes>::type;
} // namespace meta
Now we can write:
using fields = meta::type_seq<std::string_view, int, double, char>;
static_assert(std::same_as<
meta::select_t<fields, std::index_sequence<2, 0, 2>>,
meta::type_seq<double, std::string_view, double>
>);
The important part is this:
Ts...[Is]...
It expands over Is..., not over Ts... directly. For every index in Is...,
the expression selects one element from Ts.... That means the selected list can
reorder elements and can also repeat them.
The same shape works for values:
template <std::size_t... Is, typename... Ts>
requires valid_indices<sizeof...(Ts), Is...>
constexpr auto pick_values(Ts&&... values) {
return std::tuple<std::remove_cvref_t<Ts...[Is]>...>{
std::forward<Ts...[Is]>(values...[Is])...
};
}
There are two pack-indexing operations here:
Ts...[Is]
values...[Is]
The first one selects the type used for forwarding. The second one selects the
actual function parameter. This is exactly the kind of code that used to require
std::get, std::forward_as_tuple, and a helper function just to recover the
relationship between a forwarded value and its original type.
Selected Invocation
Another useful example is calling a function with only selected arguments. This comes up when a generic wrapper receives a full argument list, but an inner policy only cares about a subset of it.
For example, suppose a request pipeline has path, status_code, and
latency_ms, but the sampling policy only needs latency_ms and path:
auto should_sample = meta::invoke_selected<2, 0>(
[](double latency_ms, std::string_view path) {
return latency_ms > 100.0 && path.starts_with("/api/");
},
std::string_view{"/api/orders"},
200,
143.5
);
The call should pass argument 2 and then argument 0, so the lambda receives
143.5 and "/api/orders". The status code is intentionally ignored.
The first draft might be:
template <std::size_t... Is, typename F, typename... Ts>
constexpr decltype(auto) invoke_selected(F&& fn, Ts&&... values) {
return std::invoke(
std::forward<F>(fn),
std::forward<Ts...[Is]>(values...[Is])...
);
}
This is already very compact. But I prefer adding an explicit boundary concept,
because this is not just a call to std::invocable. There are two conditions:
- every index must be valid for the original argument pack
- the function must be callable with the selected argument types
template <typename Indexes, typename F, typename... Ts>
struct selected_invocable_impl : std::false_type {};
template <std::size_t... Is, typename F, typename... Ts>
requires valid_indices<sizeof...(Ts), Is...> &&
std::invocable<F, Ts...[Is]...>
struct selected_invocable_impl<std::index_sequence<Is...>, F, Ts...>
: std::true_type {};
template <typename Indexes, typename F, typename... Ts>
concept selected_invocable =
selected_invocable_impl<Indexes, F, Ts...>::value;
Then the utility becomes:
template <std::size_t... Is, typename F, typename... Ts>
requires selected_invocable<std::index_sequence<Is...>, F, Ts...>
constexpr decltype(auto) invoke_selected(F&& fn, Ts&&... values) {
return std::invoke(
std::forward<F>(fn),
std::forward<Ts...[Is]>(values...[Is])...
);
}
I like this version more because the concept names the real boundary. Invalid indexes and non-callable selected arguments are rejected at the same API edge. The implementation body can stay small and boring.
If this is something used often, it can be wrapped as an adapter object:
template <std::size_t... Is>
struct select_args_t {
template <typename F, typename... Ts>
requires selected_invocable<std::index_sequence<Is...>, F, Ts...>
constexpr decltype(auto) operator()(F&& fn, Ts&&... values) const {
return invoke_selected<Is...>(
std::forward<F>(fn),
std::forward<Ts>(values)...
);
}
};
template <std::size_t... Is>
inline constexpr select_args_t<Is...> select_args{};
Usage:
auto should_sample = meta::select_args<2, 0>(
[](double latency_ms, std::string_view path) {
return latency_ms > 100.0 && path.starts_with("/api/");
},
std::string_view{"/api/orders"},
200,
143.5
);
The adapter does not duplicate the pack-indexing expression. It only provides a nicer object-style API. The actual selection logic still lives in one function.
A Descriptor Example
Pack indexing is also useful when treating a type pack as structured compile-time data.
For example, a field descriptor can be represented purely at the type level. The name is also a type, so it can carry a compile-time string without becoming a runtime data member:
struct id_name {
static constexpr std::string_view value = "id";
};
struct display_name {
static constexpr std::string_view value = "display";
};
template <typename Name, typename Type>
struct field {
using name = Name;
using type = Type;
};
template <typename... Fields>
struct record {};
using user_record = record<
field<id_name, int>,
field<display_name, std::string_view>
>;
Before pack indexing, selecting the Ith field usually means reusing a
type-list helper or converting Fields... through std::tuple:
template <std::size_t I, typename... Fields>
using old_record_field_t =
std::tuple_element_t<I, std::tuple<Fields...>>;
The tuple is not part of the record model. It only exists because the language could not index the pack directly. With C++26, the field selection is direct:
template <std::size_t I, typename Record>
struct record_field;
template <std::size_t I, typename... Fields>
requires (I < sizeof...(Fields))
struct record_field<I, record<Fields...>> {
using type = Fields...[I];
};
template <std::size_t I, typename Record>
using record_field_t = typename record_field<I, Record>::type;
That looks small, but it changes the way the code reads. record<Fields...> is
the data model, and Fields...[I] is the selection operation. There is no extra
container in the middle.
It also composes nicely with value-level metadata. If the record exposes a metadata table, a compile-time lookup can bridge back into type-level selection:
constexpr auto display_index =
meta::find_field_index<user_record>("display");
using display_field =
meta::record_field_t<display_index, user_record>;
using display_type = typename display_field::type;
static_assert(std::same_as<display_type, std::string_view>);
This bridge only works when the index is a constant expression. That is an important boundary. Pack indexing does not turn a type pack into a runtime container. It lets compile-time indexes select from compile-time packs.
Limitations
The index must be a constant expression and must be inside the pack. Empty packs cannot be indexed. There is also no negative indexing, so if you want the last element, spell it out:
template <typename... Ts>
requires (sizeof...(Ts) > 0)
using last_type_t = Ts...[sizeof...(Ts) - 1];
Also, C++26 pack indexing covers type packs and expression packs. Indexing packs of template names is a separate follow-up direction. See P3670R0 if you are interested in that part.
Compiler support is still something to check before using it in production
code. I tested the examples above with Clang 19.1.7 and -std=c++2c.
Summary
Pack indexing is not a big feature in surface area, but it removes a very common piece of metaprogramming ceremony.
Previously, when I wanted one element from a pack, I often had to introduce
std::tuple, std::tuple_element_t, std::index_sequence, or a recursive
helper. With C++26, the code can say the thing directly:
Ts...[I]
values...[I]
The feature is especially nice when the surrounding abstraction already has a clear boundary: selecting fields from a record, projecting a subset of argument types, or invoking a function with reordered arguments. In those cases, pack indexing does not just save a few lines. It makes the code match the mental model.