Foundations Updated 2026-09 View as Markdown

Enums, typedef and const

Three small features that decide how readable a C codebase is — and one of them is your only real defence against accidental mutation.

enum#

An enum names a set of related integer constants.

c
typedef enum {
    STATUS_PENDING,      /* 0 */
    STATUS_SHIPPED,      /* 1 */
    STATUS_DELIVERED,    /* 2 */
    STATUS_CANCELLED,    /* 3 */
} order_status_t;

order_status_t s = STATUS_SHIPPED;

Values start at 0 and increment unless you set them:

c
typedef enum {
    ERR_NONE       = 0,
    ERR_NOT_FOUND  = 404,
    ERR_CONFLICT   = 409,
    ERR_INTERNAL   = 500,
} http_error_t;

Why this beats a set of #defines: the constants have a type, they appear by name in a debugger, and the compiler can warn when a switch misses one.

c
switch (status) {
case STATUS_PENDING:   return "pending";
case STATUS_SHIPPED:   return "shipped";
case STATUS_DELIVERED: return "delivered";
case STATUS_CANCELLED: return "cancelled";
}

Compile with -Wswitch (included in -Wall) and adding a new enum member turns every switch that does not handle it into a warning. That is the closest C gets to an exhaustiveness check, and it is worth building on: omit the default case in switches over an enum, or the compiler can no longer tell you anything.

Enums are also the idiomatic way to declare an integer constant:

c
enum { MAX_CONNECTIONS = 256 };
char buffer[MAX_CONNECTIONS];       /* usable as an array size */

typedef#

typedef gives a type another name.

c
typedef unsigned char byte_t;
typedef struct node node_t;

typedef struct {
    double x, y;
} point_t;

point_t p = { .x = 1.0, .y = 2.0 };     /* rather than: struct point p; */

Two conventions worth following:

  • Suffix with _t so a reader knows the identifier is a type. (Strictly, POSIX reserves _t for itself; in practice most codebases use it anyway. _type is the cautious alternative.)
  • Do not typedef away a pointer.
c
typedef struct node *node_ptr_t;    /* hides the pointer — avoid */
node_ptr_t a, b;                    /* both pointers, but it does not read that way */

Hiding the * means readers cannot tell what needs freeing, what can be NULL, and what is passed by reference. The standard library's FILE * is the model: the struct is typedef'd, the pointer is written out.

Self-referential structs#

c
typedef struct node {           /* the tag `node` is needed inside the struct */
    int value;
    struct node *next;
} node_t;

A struct cannot use its own typedef name before the typedef finishes, so self-referential types need the tag as well.

const#

C's only tool for expressing "this must not change", and the most under-used feature in the language.

c
const int max = 100;
max = 200;                  /* error */

Where it earns its place is function parameters:

c
size_t count_words(const char *text);       /* promises not to modify text */
void normalise(char *text);                 /* will modify it */

The signature now documents intent and the compiler enforces it. A caller can see at a glance which functions mutate their arguments — information that otherwise requires reading the body.

Reading const with pointers#

The rule: const applies to what is on its left, unless there is nothing there, in which case it applies to the right.

c
const char *p;          /* pointer to const char — data is fixed, p can move */
char const *p;          /* identical to the above */
char *const p;          /* const pointer to char — p is fixed, data can change */
const char *const p;    /* both fixed */
c
const char *p = "hello";
p[0] = 'H';             /* error — data is const */
p = "world";            /* fine — the pointer is not */

char *const q = buffer;
q[0] = 'H';             /* fine */
q = other;              /* error */

const char * is the one you write most: a function that reads a string should take it.

const correctness pays off#

c
/* before: can this modify my buffer? no way to tell without reading it */
int parse_header(char *input, size_t len, header_t *out);

/* after: reads input, writes out. obvious from the signature. */
int parse_header(const char *input, size_t len, header_t *out);

Adding const to a large codebase is contagious — one const parameter forces the functions it calls to be const too — which is why it is far easier to start with it than to retrofit.

Exercise#

c
#include <stdio.h>

int main(void) {
    /* 1. Define a `log_level_t` enum: DEBUG, INFO, WARN, ERROR.
       2. Write `const char *level_name(log_level_t)` using a switch with NO
          default, so -Wswitch catches a missing case later.
       3. Write `int count_char(const char *s, char c)` — note the const.
       4. Define a `point_t` struct via typedef and a `translate` function
          taking a `point_t *` (mutable) and a `const point_t *` (offset).  */
    printf("start\n");
    return 0;
}

Common questions#

enum or #define for constants?#

enum for integers. The constants get a type, show up by name in a debugger, respect scope, and let -Wswitch find unhandled cases. A #define is untyped text substitution with none of those properties.

Should I typedef every struct?#

It is a style choice and both conventions are common. Typedefs read more cleanly; writing struct foo everywhere makes it obvious that the type is a struct. The one thing to avoid either way is typedefing a pointer, which hides information readers need.

Why bother with const if it is only compile-time?#

Because it is a checked contract. It documents which parameters a function mutates, catches accidental writes, and enables some optimisations. Its real value is on function signatures, where it tells a reader what happens without them reading the body.

Get the C agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for C. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.