Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .behavior/v2026_01_29.error-codes/.bind/vlad.error-codes.flag
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
branch: vlad/error-codes
bound_by: init.behavior skill
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
emit your response to the feedback into
- .behavior/v2026_01_29.error-codes/$BEHAVIOR_REF_NAME.[feedback].v1.[taken].by_robot.md

1. emit your response checklist
2. exec your response plan
3. emit your response checkoffs into the checklist

---

first, bootup your mechanics briefs again

npx rhachet roles boot --repo ehmpathy --role mechanic

---
---
---


# blocker.1

---

# nitpick.2

---

# blocker.3
45 changes: 45 additions & 0 deletions .behavior/v2026_01_29.error-codes/0.wish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
wish =

would be awesome if it was easy to declare error codes per error

both, custom slugs

as well as http

e.g.,

error.code.{ slug?: string, http?: number }

---

by default, helpful error has code.http undefined

but BadRequestError is a 400 error

and UnexpectedCodepathError is a 500 error

---

also, should be easy for users to supply

code: { slug }

via the metadata/options input

similar to how easily they're able to supply 'cause'

---

also, code should be extractable

and extensible to be baked in on every subclass

so that if someone wants to create an

```ts
AuthorizationError extends BadRequestError {
code = { slug: "AUTHZ", http: 403 }
}
```

then they could
282 changes: 282 additions & 0 deletions .behavior/v2026_01_29.error-codes/1.vision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
# vision: error codes for helpful-errors

> paint a picture of what the world looks like when this wish is fulfilled

---

## the outcome world

### before: scattered error handle chaos

a typical day in a production incident debug session:

```ts
// somewhere in the api handler
app.use((err, req, res, next) => {
// what status code do i return?
if (err instanceof BadRequestError) {
res.status(400).json({ error: err.message }); // hardcoded assumption
} else if (err instanceof UnexpectedCodePathError) {
res.status(500).json({ error: err.message }); // another hardcoded assumption
} else if (err.name === 'AuthorizationError') {
res.status(403).json({ error: err.message }); // string match yikes
} else if (err.message.includes('not found')) {
res.status(404).json({ error: err.message }); // message parse double yikes
} else {
res.status(500).json({ error: 'internal error' }); // give up
}
});
```

```ts
// somewhere in a service
throw new BadRequestError('invalid input', { userId }); // no machine-readable code
// downstream: how does the caller know this is INVALID_INPUT vs DUPLICATE_EMAIL vs EXCEEDED:RATELIMIT?
// they parse the message string. fragile.
```

```ts
// somewhere else, a custom error
class RateLimitError extends Error {
constructor(message: string) {
super(message);
this.name = 'RateLimitError';
// no standard way to declare this is a 429
// no standard way to declare a machine-readable code like EXCEEDED:RATELIMIT
}
}
```

the pain points:
- http status codes are scattered across error handlers
- machine-readable codes don't exist or are inconsistent
- every team invents their own conventions
- error classification logic is duplicated everywhere
- api responses are inconsistent, docs are wrong, clients are confused

### after: declarative error codes baked in

the same day, but peaceful:

```ts
// error declarations are self-evident
class RatelimitError extends BadRequestError {
public static code = { http: 429, slug: 'EXCEEDED:RATELIMIT' } as const;
}
class AuthorizationError extends BadRequestError {
public static code = { http: 403, slug: 'FORBIDDEN:UNAUTHORIZED' } as const;
}
```

```ts
// the api handler is now trivial
app.use((err, req, res, next) => {
const code = err.code ?? { http: 500 };
res.status(code.http ?? 500).json({
error: err.message,
code: code.slug,
});
});
```

```ts
// throw errors with contextual codes, when needed
throw new BadRequestError('email already registered', {
code: { slug: 'FORBIDDEN:DUPLICATE' },
email,
});
```

```ts
// extract codes for logs, metrics, route decisions
if (error.code?.slug === 'EXCEEDED:RATELIMIT') {
await notifyOpsChannel({ error });
}
metrics.increment(`errors.${error.code?.slug ?? 'UNKNOWN'}`);
```

### the "aha" moment

the value clicks when a developer realizes:

> "i can define my error taxonomy once, and everywhere in my system—handlers, clients, logs, metrics, docs—speaks the same language. the http status code lives with the error, not scattered across switch statements."

or when an on-call engineer sees:

> "the alert says `code: DECLINED:PAYMENT`. i know exactly what happened without a message string parse. and the client received a 402, not a generic 400."

---

## user experience

### usecases and goals

| usecase | goal | how |
| ------------------- | ---------------------------- | ---------------------------------------------------- |
| **api author** | return correct http status | `res.status(error.code.http)` |
| **api author** | return machine-readable code | `{ code: error.code.slug }` |
| **error definer** | bake in standard codes | `static code = { http, slug }` |
| **error thrower** | override code for context | `throw new XError(msg, { code: { slug } })` |
| **error handler** | route by code | `if (error.code.slug === 'X')` |
| **logger/metrics** | categorize errors | `log({ code: error.code.slug })` |
| **client consumer** | understand error type | parse `{ code: 'EXCEEDED:RATELIMIT' }` from response |

### contract inputs & outputs

#### define an error class with baked-in code

```ts
// input: class declaration with static code
class PaymentDeclinedError extends BadRequestError {
public static code = { http: 402, slug: 'DECLINED:PAYMENT' } as const;
}

// output: instances have .code accessor
const error = new PaymentDeclinedError('card rejected', { cardLast4: '1234' });
error.code // => { http: 402, slug: 'DECLINED:PAYMENT' }
```

#### throw with an ad-hoc code

```ts
// input: code in metadata
throw new BadRequestError('email already in use', {
code: { slug: 'DUPLICATE_EMAIL' },
email,
});

// output: .code reflects the override
error.code // => { http: 400, slug: 'DUPLICATE_EMAIL' } // http from BadRequestError default
```

#### extract codes

```ts
// output: strongly typed accessor
const slug = error.code?.slug; // string | undefined
const http = error.code?.http; // number | undefined

// safe to use in conditionals, switches, maps
switch (error.code?.slug) {
case 'EXCEEDED:RATELIMIT': return retryLater();
case 'DECLINED:PAYMENT': return showPaymentForm();
default: return showGenericError();
}
```

### timeline: typical adoption path

1. **day 0**: upgrade helpful-errors, see new `.code` accessor
2. **day 1**: add `static code` to custom errors that already exist
3. **week 1**: simplify api error handlers to use `error.code.http`
4. **week 2**: standardize api responses to include `code: error.code.slug`
5. **month 1**: clients rely on `.code.slug` instead of message parse
6. **continual**: new errors are defined with codes from the start

---

## mental model

### how users would describe this to a friend

> "you know how http has status codes like 404 and 500? helpful-errors lets you attach those directly to your error classes. plus you can add your own codes like 'DECLINED:PAYMENT' or 'EXCEEDED:RATELIMIT'. then anywhere you catch the error, you just read `.code.http` and `.code.slug` instead of janky string match."

### analogies

| analogy | explanation |
| -------------------------------- | ------------------------------------------------------------------------------------------------ |
| **http status codes for errors** | just as http responses have status codes, errors have `.code.http` |
| **enum-like slugs** | `.code.slug` is like an enum value—machine-readable, finite, documented |
| **barcode on a product** | the error message is the label, the code is the barcode—humans read one, machines read the other |
| **exit codes for processes** | unix programs return exit codes; errors return `.code` |

### terms: user language vs library language

| user might say | library calls it |
| -------------- | -------------------------------- |
| "error type" | `code.slug` |
| "status code" | `code.http` |
| "error code" | `code` (the object) |
| "tagged error" | error with `static code` defined |
| "custom code" | `code` passed via metadata |

---

## evaluation

### how well does it solve the goals?

| goal | solved? | notes |
| ---------------------------------- | ------- | ------------------------------------------ |
| easy to declare error codes | yes | `static code = { http, slug }` |
| easy to supply codes at throw time | yes | `{ code: { slug } }` in metadata |
| easy to extract codes | yes | `error.code.slug`, `error.code.http` |
| baked-in defaults for subclasses | yes | `BadRequestError.code.http = 400` |
| extensible for user subclasses | yes | class-level `static code` |
| backwards compatible | yes | `.code` is additive, prior code unaffected |

### pros

- **declarative**: codes live with error definitions, not scattered in handlers
- **type-safe**: `.code.slug` and `.code.http` are typed
- **composable**: class-level defaults + instance-level overrides
- **minimal api surface**: just one property (`.code`) with two fields
- **http-aligned**: mirrors the http status code mental model developers know
- **machine-readable**: enables automation (route decisions, metrics, alerts)

### cons

- **learn curve**: users must understand code hierarchy (class default vs instance override)
- **slug discipline**: teams need conventions for slug name (e.g., SCREAMING_SNAKE)
- **http not always relevant**: some errors (cli tools, background jobs) don't need http codes
- **migration effort**: codebases that already exist need to add `static code` to custom errors

### edge cases and pit-of-success design

| edge case | pit-of-success behavior |
| ----------------------------------------- | ------------------------------------------- |
| no code defined anywhere | `.code` returns `undefined` (not an error) |
| class has code, instance doesn't override | `.code` returns class code |
| instance overrides with partial code | merges: `{ ...classCode, ...instanceCode }` |
| user passes `code: null` | clears the code (explicit opt-out) |
| http code without slug | valid: `{ http: 418 }` |
| slug without http code | valid: `{ slug: 'TEAPOT' }` |
| code on HelpfulError base | `undefined` by default (no assumptions) |
| code on BadRequestError | `{ http: 400 }` by default |
| code on UnexpectedCodePathError | `{ http: 500 }` by default |

### serialization: don't spam logs with defaults

the `.code` getter always returns the full code object for programmatic access. but serialization (toJSON, logs) omits code unless explicitly set — to prevent log spam.

| scenario | `.code` getter returns | serialized (toJSON) |
| -------- | ---------------------- | ------------------- |
| `new BadRequestError('x')` | `{ http: 400 }` | code omitted |
| `new BadRequestError('x', { code: { slug: 'X' } })` | `{ http: 400, slug: 'X' }` | `{ code: { http: 400, slug: 'X' } }` |
| `new PaymentDeclinedError('x')` (class has slug) | `{ http: 402, slug: 'DECLINED:PAYMENT' }` | `{ code: { http: 402, slug: 'DECLINED:PAYMENT' } }` |
| `new HelpfulError('x')` | `undefined` | code omitted |

**the rule**: code appears in serialization only when a slug is present (either from class or instance).

this keeps logs clean:
```ts
// before: every BadRequestError logs { code: { http: 400 } } — noise
// after: only errors with slugs show code — signal
```

### awkwardness uncovered

1. **type inference**: to ensure `error.code.slug` infers correctly when generics are used (`HelpfulError<TMetadata>`) requires careful type work. the `code` metadata field should not pollute `TMetadata` inference.

---

## summary

when this wish is fulfilled:

- every error can declare its http status and machine-readable slug
- api handlers become trivial: `res.status(error.code?.http ?? 500)`
- clients receive consistent, parseable responses: `{ code: 'EXCEEDED:RATELIMIT' }`
- metrics and logs categorize errors by code, not string match
- the error taxonomy is self-evident in class definitions
- backwards compatibility is preserved: prior code works unchanged
38 changes: 38 additions & 0 deletions .behavior/v2026_01_29.error-codes/1.vision.src
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
illustrate the vision implied in the wish .behavior/v2026_01_29.error-codes/0.wish.md

emit into .behavior/v2026_01_29.error-codes/1.vision.md

---

paint a picture of what the world looks like when this wish is fulfilled

testdrive the contract we propose via realworld examples

specifically,

## the outcome world

- what does a day-in-the-life look like with this in place?
- what's the before/after contrast?
- what's the "aha" moment where the value clicks?

## user experience

- what usecases do folks fulfill? what goals?
- what contract inputs & outputs do they leverage?
- what would it look like to leverage them?
- what timelines do they go through?

## mental model

- how would users describe this to a friend?
- what analogies or metaphors fit?
- what terms would they use vs what terms would we use?

## evaluation

- how well does it solve the goals?
- what are the pros? the cons?
- what edgecases exist and how do our contracts keep users in a pit of success?

uncover anything awkward
Loading