Skip to main content
C intermediate Lesson 13 of 23

Unions and Enums in C

Understand union memory layout, how to use enum and typedef enum, and practical use cases for both.

Union Basics and Memory Layout

A union allocates enough memory for its largest member, and all members share that same memory region. Only one member holds a valid value at any time — writing to one member invalidates all others. This makes unions memory-efficient for representing values that can be one of several types, but requires discipline to use safely.

#include <stdio.h>

union Data {
    int    i;
    float  f;
    double d;
    char   bytes[8];
};

int main(void) {
    union Data u;

    /* sizeof equals the largest member (double = 8 bytes) */
    printf("sizeof(union Data) = %zu\n", sizeof(union Data));  /* 8 */

    u.i = 42;
    printf("i = %d\n", u.i);

    u.f = 3.14f;
    printf("f = %.2f\n", u.f);
    /* u.i is now meaningless — we overwrote the shared memory */

    /* Inspect the raw bytes of a float — all members share the same address */
    u.f = 1.0f;
    printf("1.0f in hex: ");
    for (int i = 0; i < (int)sizeof(float); i++) {
        printf("%02X ", (unsigned char)u.bytes[i]);
    }
    printf("\n");   /* 00 00 80 3F (little-endian IEEE 754) */

    return 0;
}

Tagged Union (Discriminated Union)

The most practical use of unions is the tagged union pattern: pair a union with an enum tag that records which member is currently valid. This gives you a type-safe variant — a value that can be an int, a float, a string, or any other type, with the type tracked explicitly at runtime.

#include <stdio.h>
#include <string.h>
#include <stdlib.h>

/* The tag enum — records which union member is active */
typedef enum {
    VAL_INT,
    VAL_FLOAT,
    VAL_STRING,
    VAL_BOOL
} ValueType;

/* The tagged union — tag + union together */
typedef struct {
    ValueType type;
    union {
        int    i;
        double f;
        char  *s;   /* heap-allocated string */
        int    b;   /* boolean: 0 or 1 */
    };
} Value;

Value make_int(int i)          { return (Value){.type=VAL_INT,    .i=i}; }
Value make_float(double f)     { return (Value){.type=VAL_FLOAT,  .f=f}; }
Value make_bool(int b)         { return (Value){.type=VAL_BOOL,   .b=!!b}; }
Value make_string(const char *s) {
    Value v = {.type = VAL_STRING};
    v.s = strdup(s);
    return v;
}

void print_value(const Value *v) {
    switch (v->type) {
        case VAL_INT:    printf("int(%d)",    v->i); break;
        case VAL_FLOAT:  printf("float(%.4g)", v->f); break;
        case VAL_STRING: printf("string(\"%s\")", v->s); break;
        case VAL_BOOL:   printf("bool(%s)",   v->b ? "true" : "false"); break;
    }
}

void free_value(Value *v) {
    if (v->type == VAL_STRING) {
        free(v->s);
        v->s = NULL;
    }
}

int main(void) {
    Value vals[] = {
        make_int(42),
        make_float(3.14159),
        make_string("hello"),
        make_bool(1),
    };
    int n = sizeof(vals) / sizeof(vals[0]);

    for (int i = 0; i < n; i++) {
        print_value(&vals[i]);
        printf("\n");
        free_value(&vals[i]);
    }

    return 0;
}

Type Punning with Unions

Unions are commonly used to inspect the raw bit representation of a value — for example, examining the sign, exponent, and mantissa fields of a IEEE 754 float. This technique is called type punning.

#include <stdio.h>
#include <stdint.h>

union FloatBits {
    float    f;
    uint32_t bits;
};

void print_float_bits(float f) {
    union FloatBits u = {.f = f};
    /* IEEE 754 single: 1 sign bit, 8 exponent bits, 23 mantissa bits */
    printf("%.6g = 0x%08X (sign=%u exp=%u mant=%u)\n",
           f,
           u.bits,
           (u.bits >> 31) & 0x1,
           (u.bits >> 23) & 0xFF,
           u.bits & 0x7FFFFF);
}

int main(void) {
    print_float_bits(1.0f);
    print_float_bits(-1.0f);
    print_float_bits(0.5f);
    print_float_bits(3.14f);
    return 0;
}

Enums

An enum defines a set of named integer constants. Without enums, you’d use bare integer literals or #define constants — both of which are harder to read and provide no type safety. Enums group related constants under a single type name and make switch statements self-documenting.

#include <stdio.h>

/* Basic enum — values 0, 1, 2, 3 assigned automatically */
enum Direction { NORTH, EAST, SOUTH, WEST };

/* Custom values — useful for HTTP status codes, error codes, etc. */
enum HttpStatus {
    HTTP_OK           = 200,
    HTTP_CREATED      = 201,
    HTTP_NO_CONTENT   = 204,
    HTTP_BAD_REQUEST  = 400,
    HTTP_UNAUTHORIZED = 401,
    HTTP_FORBIDDEN    = 403,
    HTTP_NOT_FOUND    = 404,
    HTTP_SERVER_ERROR = 500,
};

/* Bit-flag enum — each value is a distinct power of 2, so they can be OR'd together */
enum Permission {
    PERM_NONE    = 0,
    PERM_READ    = 1 << 0,   /* 1 */
    PERM_WRITE   = 1 << 1,   /* 2 */
    PERM_EXECUTE = 1 << 2,   /* 4 */
    PERM_ALL     = PERM_READ | PERM_WRITE | PERM_EXECUTE,
};

const char *direction_name(enum Direction d) {
    switch (d) {
        case NORTH: return "North";
        case EAST:  return "East";
        case SOUTH: return "South";
        case WEST:  return "West";
        default:    return "Unknown";
    }
}

int main(void) {
    enum Direction dir = EAST;
    printf("Heading: %s\n", direction_name(dir));

    /* Enum values are just integers under the hood */
    printf("NORTH=%d EAST=%d SOUTH=%d WEST=%d\n", NORTH, EAST, SOUTH, WEST);

    /* Combine bit flags with OR, test with AND */
    int perms = PERM_READ | PERM_WRITE;
    if (perms & PERM_WRITE) printf("Can write\n");
    if (!(perms & PERM_EXECUTE)) printf("Cannot execute\n");

    perms |= PERM_EXECUTE;   /* grant execute */
    perms &= ~PERM_WRITE;    /* revoke write */

    return 0;
}

typedef enum

typedef eliminates the need to write enum before every use, making enum types feel like first-class types. The sentinel pattern — adding a _COUNT member at the end — is especially useful for sizing arrays and validating input.

#include <stdio.h>

typedef enum {
    LOG_DEBUG,
    LOG_INFO,
    LOG_WARNING,
    LOG_ERROR,
    LOG_FATAL,
    LOG_LEVEL_COUNT   /* sentinel: its integer value equals the number of valid levels */
} LogLevel;

/* Use the sentinel to size the lookup table — stays in sync automatically */
static const char *LEVEL_NAMES[LOG_LEVEL_COUNT] = {
    "DEBUG", "INFO", "WARNING", "ERROR", "FATAL"
};

void log_message(LogLevel level, const char *msg) {
    if (level < LOG_DEBUG || level >= LOG_LEVEL_COUNT) return;
    printf("[%s] %s\n", LEVEL_NAMES[level], msg);
}

/* State machine — enum makes the states self-documenting */
typedef enum {
    STATE_IDLE,
    STATE_CONNECTING,
    STATE_CONNECTED,
    STATE_DISCONNECTING,
    STATE_ERROR,
} ConnectionState;

const char *state_name(ConnectionState s) {
    static const char *names[] = {
        "IDLE", "CONNECTING", "CONNECTED", "DISCONNECTING", "ERROR"
    };
    if (s < 0 || s >= (int)(sizeof(names)/sizeof(names[0]))) return "UNKNOWN";
    return names[s];
}

int main(void) {
    log_message(LOG_INFO,    "Server started");
    log_message(LOG_WARNING, "High memory usage");
    log_message(LOG_ERROR,   "Connection refused");

    ConnectionState state = STATE_IDLE;
    printf("State: %s\n", state_name(state));
    state = STATE_CONNECTING;
    printf("State: %s\n", state_name(state));
    state = STATE_CONNECTED;
    printf("State: %s\n", state_name(state));

    return 0;
}

A useful pattern: add a sentinel like LOG_LEVEL_COUNT or STATE_COUNT at the end of an enum. Its integer value equals the number of valid values, making it easy to size arrays and validate inputs.

Frequently Asked Questions

What is the difference between a union and a struct?
In a struct, each member has its own memory location. In a union, all members share the same memory — the size of a union equals its largest member. Writing to one member and reading from another (type punning) is technically undefined behavior in C, except for reading the common initial sequence of two structs.
When would I actually use a union?
Unions are useful for: variant types (a value that can be one of several types), memory-efficient data structures, type punning for low-level bit manipulation, and implementing tagged unions (discriminated unions).
Can enum values be negative?
Yes. You can assign any integer constant to an enum member. The underlying type is implementation-defined but must be able to represent all assigned values.