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.
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:
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.
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:
enum { MAX_CONNECTIONS = 256 };
char buffer[MAX_CONNECTIONS]; /* usable as an array size */typedef#
typedef gives a type another name.
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
_tso a reader knows the identifier is a type. (Strictly, POSIX reserves_tfor itself; in practice most codebases use it anyway._typeis the cautious alternative.) - Do not typedef away a pointer.
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#
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.
const int max = 100;
max = 200; /* error */Where it earns its place is function parameters:
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.
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 */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#
/* 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#
#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.
AGENTS.md now — no email needed.