Skip to content
C++
Language
Intermediate

Elaborated Type Specifiers

A class-key or enum keyword prefix that disambiguates a type name from other names in scope, enables forward declarations, and resolves lookup in complex contexts.

Elaborated Type Specifiersince C++98

An elaborated type specifier is a type name prefixed by class, struct, union, or enum that unambiguously refers to a type even when a non-type name of the same identifier is in scope, and that can introduce a forward declaration of a class type as a side effect.

Overview

Elaborated type specifiers exist to solve two distinct but related problems. The first is disambiguation: C++ allows a non-type name (a variable, function, or enumerator) to shadow a type name in the same scope. The second is forward declaration: you often need to name a type before its full definition is available, particularly when building mutually recursive data structures or breaking circular header dependencies.

The syntax takes one of these forms:

  • class identifier / struct identifier / union identifier β€” refers to or introduces a class type
  • enum identifier β€” refers to a previously declared enumeration (cannot introduce a new one)

These prefixes are collectively called class-keys for class types, and enum for enumeration types. In every context where a simple name would be ambiguous or insufficient, the elaborated form is unambiguous.

Syntax

cpp
// Basic elaborated forms
struct Point { int x, y; };
class Engine { /* ... */ };
union Variant { int i; float f; };
enum Color { Red, Green, Blue };

void f() {
    int Point = 42;           // shadows the struct name

    // Point p;               // error: Point is the int variable here
    struct Point p = {1, 2};  // OK: elaborated specifier bypasses the variable
    (void)p;
}

The class and struct keywords are interchangeable in elaborated type specifiers β€” they both refer to any non-union class type regardless of how it was originally declared:

cpp
struct Foo {};
class Foo bar;   // OK: 'class' and 'struct' are interchangeable here
struct Foo baz;  // also OK

union must be used for unions, and enum must be used for enumerations. Mismatching the keyword is ill-formed:

cpp
enum class Status { OK, Fail };  // scoped enum (C++11)

enum Status s = Status::OK;       // OK
// enum class Status t = Status::OK; // error: 'enum class' is not a valid elaborated specifier

Forward Declarations

The most common use of elaborated type specifiers is to forward-declare a class type. A declaration that consists solely of an elaborated type specifier for a class introduces the name into the nearest enclosing namespace or block scope:

cpp
// Forward declarations in a header β€” no #include needed
struct Node;       // declares Node at namespace scope
class Allocator;   // declares Allocator at namespace scope

struct Node {
    int value;
    Node* next;      // OK: pointer to incomplete type
    struct Data* metadata; // OK: also forward-declares 'Data' at global scope
};

This form is the standard technique for breaking circular dependencies between headers. As long as you only need a pointer or reference to the type β€” not its size or members β€” an elaborated forward declaration suffices.

Note that enum types cannot be forward-declared with an elaborated type specifier alone. Since C++11, opaque enum declarations (enum E : int;) serve that purpose and produce a complete type immediately.

Name Disambiguation

When a non-type name shadows a class name, an elaborated type specifier instructs the compiler to skip non-type names during lookup:

cpp
class Buffer {
public:
    class Config {};
private:
    int Config;  // data member shadows the nested class name
};

void setup(Buffer& b) {
    // Buffer::Config cfg;         // error: finds the int member
    class Buffer::Config cfg;      // OK: non-type member is ignored
    (void)cfg;
}

This also applies at function scope, where local variables can shadow type names from outer scopes:

cpp
struct Connection {};

void connect() {
    int Connection = -1;           // shadows the struct

    // Connection c;               // error
    struct Connection c;           // OK: resolves to the struct
    (void)c;
}

Qualified elaborated type specifiers β€” those using :: β€” follow qualified name lookup rules, but still suppress non-type names at each lookup step.

Elaborated Specifiers in Templates

Inside a template, elaborated type specifiers interact with the injected class name and with dependent name rules:

cpp
template <typename T>
struct List {
    struct List* head;   // OK: injected-class-name 'List' is found first

    struct Node* node;   // OK: forward-declares 'Node' at the enclosing namespace scope
                         // (not inside the template instantiation)
};

List<int>::Node* p = nullptr;  // 'Node' is at global/namespace scope, not List<int>::Node

A subtle restriction: a template type parameter cannot appear in an elaborated type specifier. The canonical friend declaration form for a type parameter is the unelaborated friend T; (C++11), not friend class T;:

cpp
template <typename T>
class Secure {
    // friend class T;  // error: type parameter in elaborated-type-specifier
    friend T;           // OK (C++11): grants T friendship directly
};

Examples

Resolving a name conflict from a legacy C struct typedef:

cpp
// C-originated header (cannot modify)
typedef struct tagRect { int l, t, r, b; } RECT;
int Rect = 0; // also in scope from some macro-heavy header

void process() {
    struct tagRect bounds = {0, 0, 100, 200}; // bypasses the int 'Rect'
    // ...
}

Mutually recursive data structures across headers:

cpp
// graph_node.h
struct Edge;  // forward declaration β€” no edge.h include needed

struct GraphNode {
    int id;
    Edge* edges;
    std::size_t edge_count;
};

// edge.h
#include "graph_node.h"

struct Edge {
    GraphNode* from;
    GraphNode* to;
    double weight;
};

Using elaborated specifiers in function signatures to avoid headers:

cpp
// In a .cpp file β€” avoids polluting the header with an #include
void enqueue(struct Task* t);
void enqueue(struct Task* t) {
    // full definition of Task available here via its own header
}

Best Practices

Keep elaborated type specifiers in forward-declaration contexts (struct Foo; at namespace scope) as the primary tool for reducing header coupling. Avoid relying on them for disambiguation β€” if a non-type name is shadowing a type name in your own code, renaming one of them is almost always the better fix.

Do not use struct/class prefixes on every type reference as a C habit. Modern C++ typedef-free struct definitions do not require it, and sprinkling elaborated prefixes throughout call sites adds noise without benefit. Reserve them for the situations described above.

When writing template code, prefer friend T; over friend class T; for type-parameter friends. The elaborated form is ill-formed for template parameters and the compiler error can be confusing without knowing this rule.

Common Pitfalls

Using enum class as an elaborated specifier. The token sequence enum class is only valid in an enum declaration, never in a reference to an existing scoped enum:

cpp
enum class Dir { N, S, E, W };  // C++11

// enum class Dir d = Dir::N;   // error: 'enum class' is not an elaborated-type-specifier
enum Dir d = Dir::N;             // OK: just 'enum' is the correct prefix

Assuming the forward-declared name enters the template's scope. As shown above, struct Foo* inside a class template injects Foo at the enclosing namespace, not as a nested type. Code that later tries Template<T>::Foo will fail.

Introducing a name at the wrong scope. If you write struct Foo* p; inside a function body where Foo has not yet been declared anywhere, Foo is introduced at the nearest enclosing namespace scope β€” not at function scope. This is a well-defined but non-obvious rule that can surprise readers:

cpp
void f() {
    struct Hidden* p = nullptr;  // 'Hidden' is declared at namespace scope, not inside f()
}

Hidden* q = nullptr;  // compiles β€” Hidden is globally visible after f's declaration

Incomplete Types and What You Can Do With Them

When you write struct Foo; at namespace scope, you produce a forward declaration β€” an elaborated type specifier used as a standalone declaration. The name Foo is now known to the compiler as a class type, but its layout, size, and members are not yet known. Such a type is called incomplete.

The Standard distinguishes carefully between what is and is not legal with an incomplete type:

Allowed with an incomplete type:

  • Declaring a pointer or reference to it β€” Foo* p;, Foo& r = *p;
  • Using it as a function parameter or return type in a declaration (but not in an inline-defined function body that requires the size) β€” Foo* factory(); void consume(Foo* p);
  • Forming a pointer-to-member type for it
  • Declaring (not defining) static data members of that type inside another class

Not allowed with an incomplete type:

  • Defining an object of that type β€” Foo f; requires a complete type
  • Applying sizeof to it β€” the size is unknown
  • Dereferencing a pointer to it β€” p->member or *p require a complete type
  • Using it as a base class
  • Defining a member of that type (as opposed to a pointer to it)
cpp
struct Widget;  // forward declaration β€” Widget is now incomplete

Widget* create();           // OK: pointer in return type
void destroy(Widget* w);    // OK: pointer in parameter type
Widget* arr[10];            // OK: array of pointers

// Widget w;                // error: incomplete type β€” size unknown
// sizeof(Widget)           // error: size of incomplete type
// Widget* p; p->x = 1;    // error: cannot dereference pointer to incomplete type

// The full definition must be visible before any of the above forbidden uses:
// #include "widget.h"      // makes Widget complete

The point at which the type becomes complete is the point at which its full class definition is seen by the translation unit. After that point, all uses become legal.

PIMPL: Putting Incomplete Types to Work

The Pointer to IMPLementation idiom (PIMPL, also called the Cheshire cat or compiler firewall) exploits incomplete types deliberately. The public header forward-declares the implementation struct and holds only a pointer to it. The implementation details live entirely in the .cpp file.

cpp
// engine.h β€” public header, no implementation details visible
struct EngineImpl;  // forward declaration; Engine.h stays thin

class Engine {
public:
    Engine();
    ~Engine();          // must be defined in .cpp where EngineImpl is complete
    void start();
private:
    EngineImpl* impl_;  // pointer to incomplete type β€” this is legal
};

// engine.cpp β€” implementation details hidden here
#include "engine.h"
#include <vector>

struct EngineImpl {
    std::vector<int> cylinders;
    int rpm = 0;
};

Engine::Engine()  : impl_(new EngineImpl) {}
Engine::~Engine() { delete impl_; }
void Engine::start() { impl_->rpm = 3000; }

Because the destructor, constructor, and any method that touches impl_->member are defined only in engine.cpp β€” where EngineImpl is complete β€” the usage is valid. With std::unique_ptr as the holder instead of a raw pointer, the destructor must still be explicitly declared in the header and defined in the .cpp, otherwise the compiler-generated destructor is instantiated where EngineImpl is still incomplete and the deletion fails to compile.

class vs struct in Elaborated Type Specifiers

The C++ Standard treats class and struct as interchangeable class-keys in elaborated type specifiers. Both keywords refer to any non-union class type regardless of the keyword used in the original definition:

cpp
struct Point { int x, y; };

class Point p1;   // OK: 'class' can elaborate a struct type
struct Point p2;  // OK: 'struct' can elaborate a struct type

The elaborated specifier does not need to match the keyword in the definition. This is intentional: a common cross-language pattern is to forward-declare with struct in a C header and then define with class (or vice versa) in a C++ implementation file.

cpp
// c_api.h (included by both C and C++ code)
struct Session;               // plain struct forward declaration β€” C compatible

// session.cpp (C++ only)
class Session {               // defined with 'class' β€” private by default
    int id_;
public:
    explicit Session(int id) : id_(id) {}
};

While this is permitted by the Standard, most compilers issue a diagnostic at warning level when the class-key in the definition differs from the class-key in a prior declaration in the same translation unit. GCC and Clang emit warnings similar to "struct used where class was expected". This is a warning, not an error. The type is still the same type; the mismatch is stylistic.

The clean rule of thumb: match the keyword in the definition with the keyword in all forward declarations that appear in the same header. Use struct for forward declarations intended to be shared with C code, since C does not have the class keyword.

Enum Forward Declarations (C++11)

Prior to C++11, enumerations could not be forward-declared at all. The reason is fundamental: an unscoped enum without an explicit underlying type can have any implementation-chosen integral type as its underlying representation. Without seeing the enumerator list, the compiler does not know the size of the enum, so it cannot allocate storage for variables of that type.

C++11 introduced two mechanisms, collectively called opaque enum declarations:

cpp
// Unscoped enum: MUST specify the underlying type explicitly
enum Direction : int;       // OK in C++11: size is known (int)
enum Flags : unsigned char; // OK in C++11: size is known (unsigned char)
// enum Color;              // error: no underlying type β€” size unknown

// Scoped enum (enum class / enum struct): underlying type defaults to int
// so explicit type is optional, but may be specified
enum class Status;          // OK in C++11: defaults to int
enum class Priority : short; // OK in C++11: explicit short
enum struct Tag : uint8_t;   // OK: enum struct is synonymous with enum class

The definition must be consistent with the forward declaration: if you specified Direction : int, the definition must also say enum Direction : int { North, South, East, West };.

The practical difference between scoped and unscoped forward declarations is this:

  • Scoped (enum class) β€” strongly typed, enumerators are in the enum's own scope, no implicit conversion to integer. A forward declaration without an underlying type is valid because the default (int) is specified by the Standard.
  • Unscoped (enum) β€” enumerators leak into the enclosing scope, implicit conversion to integer is allowed. Forward declaration requires an explicit underlying type because there is no safe default.
cpp
// Typical use: break a circular dependency between headers
// color.h
enum class Color : uint8_t;  // forward declaration β€” consumers only need the name

// widget.h
#include "color.h"
class Widget {
    Color bg_color_;          // OK: scoped enum is complete because underlying type is known
public:
    void setColor(Color c);
};

// color_full.h (included only where enumerators are needed)
enum class Color : uint8_t { Red = 0, Green, Blue };

Note that even after an opaque enum declaration, a variable of that type can be defined because the size is known. This is unlike class forward declarations, where you can only form pointers and references until the definition is seen.

Disambiguating a Type Name from a Variable or Function

C++ allows a non-type name to shadow a type name in the same scope. This happens in C-heritage code and in code where macros or platform headers introduce identifiers that clash with type names you control. An elaborated type specifier instructs name lookup to skip non-type names and return only the class or enum:

cpp
int stat = 0;      // POSIX also provides a 'struct stat'

// stat s;         // error: 'stat' resolves to the int variable
struct stat s;     // OK: elaborated specifier bypasses the variable,
                   // finds the POSIX struct stat

This pattern is especially common in low-level systems code that uses POSIX or Win32 APIs. Many system headers define both a function (like stat()) and a struct (like struct stat) with the same identifier. When both are in scope, the function name wins in ordinary lookup; the elaborated prefix is the escape hatch.

An alternative to the elaborated specifier is a fully qualified name using the global-scope operator, if the type lives at namespace scope:

cpp
namespace mylib { struct Buffer {}; }
int Buffer = 42;  // local variable shadows mylib::Buffer... and anything at global scope

struct mylib::Buffer b1; // OK: elaborated specifier with qualified name
mylib::Buffer b2;        // also OK: qualified name bypasses local 'Buffer'

Both achieve disambiguation, but the elaborated form is necessary when the type lives at the same scope level as the shadowing name.

typedef with Elaborated Type Specifiers (C Legacy Pattern)

In C, struct tags and ordinary identifiers occupy separate namespaces. Writing struct Node is required every time you want to refer to the struct β€” there is no automatic typedef. The classic C workaround is:

cpp
typedef struct Node {
    int value;
    struct Node* next;  // elaborated specifier required inside the definition
                        // because 'Node' the typedef is not yet in scope
} Node;

After this declaration, both struct Node and plain Node refer to the same type. The inner use of struct Node* for the self-referential pointer uses an elaborated specifier because at that point the typedef name Node has not yet been introduced (the typedef is completed only at the closing semicolon).

In C++, struct, class, and union names are automatically injected into the ordinary namespace β€” there is no need for the typedef trick. struct Node { ... }; alone makes both struct Node and Node valid type names. The typedef-struct pattern still appears in headers that must compile as both C and C90/C99 (e.g. embedded firmware headers, public C library interfaces, and Win32-style APIs):

cpp
// Compiles as C and C++
#ifdef __cplusplus
extern "C" {
#endif

typedef struct sockaddr {
    unsigned short sa_family;
    char           sa_data[14];
} sockaddr;

#ifdef __cplusplus
}
#endif

When reading such headers in C++ code, note that you are looking at a redundant typedef (redundant from C++'s perspective but necessary for C). Adding a second typedef for the same type in C++ is allowed and is harmless.

Template Specializations and Elaborated Type Specifiers

When you specialize a class template, the elaborated type specifier in the specialization declaration must use a class-key β€” either struct or class. These two are interchangeable for specializations just as they are for ordinary class types. What does matter is that the keyword union cannot be used for a non-union specialization:

cpp
template <typename T>
struct Wrapper { T value; };

template <>
class Wrapper<int> {   // OK: 'class' used for a template originally declared with 'struct'
    int value_;
public:
    explicit Wrapper(int v) : value_(v) {}
};

Inside a template definition, the typename keyword is required to name a dependent type β€” elaborated specifiers using class or struct do not substitute for typename in dependent contexts:

cpp
template <typename Container>
void process(Container& c) {
    // class Container::iterator it;  // error: elaborated specifier does not satisfy
                                      // the dependent-name requirement
    typename Container::iterator it = c.begin(); // correct form
}

The class-key in a template parameter list β€” template <class T> versus template <typename T> β€” is purely syntactic: both mean "any type". The class keyword in that position is not an elaborated type specifier but a historical artifact of the original template syntax. It carries no semantic difference from typename.

See Also

  • reference/language/adl β€” how unqualified name lookup interacts with elaborated specifiers
  • reference/language/dependent-names β€” why dependent type names require typename, not elaborated specifiers
  • reference/language/class-template β€” injected class names and their interaction with elaborated forms
  • reference/language/conflicting-declarations β€” broader rules governing name shadowing and redeclaration