-
Notifications
You must be signed in to change notification settings - Fork 0
Replace String-Based Error Dispatching with Error Codes #70
Description
There are several places in the API that we use a brittle string matching approach to determining errors. This needs a formal definition to ensure consistency across the codebase.
Refactor the Result type to use a discriminated code field (via an as const object) for error routing, eliminating brittle string matching between handlers and routes across the codebase.
The definitions below are a general plan to implement the change. While many locations have been identified, some of the suggested codes might not be accurate. Be sure to evaluate them all.
As always, this can be split into separate subtasks if needed.
Step 1 — Define ErrorCode and AppError, update Result type
File: src/lib/types.ts
Add the following above the existing Result type definition, then update Result to use AppError as the default error shape:
// Error codes for type-safe error dispatching (mirrors stoker/http-status-codes pattern)
export const ErrorCode = {
NOT_FOUND: 'NOT_FOUND',
DUPLICATE: 'DUPLICATE',
CONFLICT: 'CONFLICT',
VALIDATION_ERROR: 'VALIDATION_ERROR',
INTERNAL_ERROR: 'INTERNAL_ERROR',
} as const;
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
export interface AppError {
code: ErrorCode;
message: string;
}
// Generic Result type (updated default error generic)
export type Result<T, E = AppError> = { ok: true; data: T } | { ok: false; error: E };What this gives you: ErrorCode.NOT_FOUND autocompletion in the IDE, consistent with how HttpStatusCodes.OK already works in every route file.
Step 2 — Update handler files (add code to every error return)
Each handler error return changes from:
return { ok: false, error: { message: 'User not found' } };to:
return { ok: false, error: { code: ErrorCode.NOT_FOUND, message: 'User not found' } };Every handler file needs to add import { ErrorCode } from '@/lib/types'; (alongside the existing Result import).
Concrete before/after — project-users.handlers.ts
Before:
if (!user) {
return { ok: false, error: { message: 'User not found' } };
}After:
if (!user) {
return { ok: false, error: { code: ErrorCode.NOT_FOUND, message: 'User not found' } };
}Before:
if (handleUniqueConstraintError(err)) {
return { ok: false, error: { message: 'User is already in this project' } };
}After:
if (handleUniqueConstraintError(err)) {
return { ok: false, error: { code: ErrorCode.DUPLICATE, message: 'User is already in this project' } };
}Before:
if (assignedContent) {
return { ok: false, error: { message: 'User still has content assigned' } };
}After:
if (assignedContent) {
return { ok: false, error: { code: ErrorCode.CONFLICT, message: 'User still has content assigned' } };
}Full mapping per handler file
src/domains/projects/project-users/project-users.handlers.ts (7 sites)
| Message | Code |
|---|---|
'User not found' |
ErrorCode.NOT_FOUND |
'User is already in this project' |
ErrorCode.DUPLICATE |
'User still has content assigned' |
ErrorCode.CONFLICT |
'User not found in project' |
ErrorCode.NOT_FOUND |
'Failed to get project users' |
ErrorCode.INTERNAL_ERROR |
'Failed to add user to project' |
ErrorCode.INTERNAL_ERROR |
'Failed to remove user from project' |
ErrorCode.INTERNAL_ERROR |
src/domains/chapter-assignments/chapter-assignments.handlers.ts (10 sites)
| Message | Code |
|---|---|
'Chapter assignment not found' (×4) |
ErrorCode.NOT_FOUND |
'Cannot submit assignment...' |
ErrorCode.VALIDATION_ERROR |
'Failed to create chapter assignment' |
ErrorCode.INTERNAL_ERROR |
'Failed to update chapter assignment' (×2) |
ErrorCode.INTERNAL_ERROR |
'Failed to fetch chapter assignment' |
ErrorCode.INTERNAL_ERROR |
'Failed to delete chapter assignment' |
ErrorCode.INTERNAL_ERROR |
'Failed to submit chapter assignment' |
ErrorCode.INTERNAL_ERROR |
'Failed to create chapter assignments for project unit' |
ErrorCode.INTERNAL_ERROR |
src/domains/users/users.handlers.ts (10 sites)
| Message | Code |
|---|---|
'Username already exists.' |
ErrorCode.DUPLICATE |
'A user with this email already exists.' |
ErrorCode.DUPLICATE |
'User not found' (×4) |
ErrorCode.NOT_FOUND |
'No Users found...' (×2) |
ErrorCode.INTERNAL_ERROR |
'Unable to create user' |
ErrorCode.INTERNAL_ERROR |
'Cannot update user' |
ErrorCode.INTERNAL_ERROR |
'Cannot delete user' |
ErrorCode.INTERNAL_ERROR |
src/domains/bibles/bibles.handlers.ts (7 sites)
| Message | Code |
|---|---|
'Bible not found' (×3) |
ErrorCode.NOT_FOUND |
'Failed to fetch bibles' |
ErrorCode.INTERNAL_ERROR |
'Failed to fetch bible' |
ErrorCode.INTERNAL_ERROR |
'Failed to fetch bibles for language' |
ErrorCode.INTERNAL_ERROR |
'Failed to create bible' |
ErrorCode.INTERNAL_ERROR |
'Failed to update bible' |
ErrorCode.INTERNAL_ERROR |
'Failed to delete bible' |
ErrorCode.INTERNAL_ERROR |
src/domains/books/books.handlers.ts (8 sites)
| Message | Code |
|---|---|
'Book not found' (×2) |
ErrorCode.NOT_FOUND |
'Failed to fetch books' |
ErrorCode.INTERNAL_ERROR |
'Failed to fetch book' (×2) |
ErrorCode.INTERNAL_ERROR |
'Failed to fetch Old Testament books' |
ErrorCode.INTERNAL_ERROR |
'Failed to fetch New Testament books' |
ErrorCode.INTERNAL_ERROR |
src/domains/languages/languages.handlers.ts (3 sites)
| Message | Code |
|---|---|
'Language not found' |
ErrorCode.NOT_FOUND |
'Failed to fetch languages' |
ErrorCode.INTERNAL_ERROR |
'Failed to fetch language' |
ErrorCode.INTERNAL_ERROR |
src/domains/translated-verses/translated-verses.handlers.ts (7 sites)
| Message | Code |
|---|---|
'Translated verse not found' |
ErrorCode.NOT_FOUND |
All 'Failed to ...' messages |
ErrorCode.INTERNAL_ERROR |
src/domains/bibles/bible-texts/bible-texts.handlers.ts (2 sites)
| Message | Code |
|---|---|
'Bible texts not found for the specified chapter' |
ErrorCode.NOT_FOUND |
'Failed to fetch bible texts' |
ErrorCode.INTERNAL_ERROR |
src/domains/bible-books/bible-books.handlers.ts (8 sites)
| Message | Code |
|---|---|
'No Bible Books found for this bible' |
ErrorCode.NOT_FOUND |
'Bible Book not found' / 'Bible book not found' (×3) |
ErrorCode.NOT_FOUND |
'Bible book already exists' / '...with these identifiers' |
ErrorCode.DUPLICATE |
'Invalid bible or book reference' (×2) |
ErrorCode.VALIDATION_ERROR |
'Unable to create bible book' |
ErrorCode.INTERNAL_ERROR |
All 'Failed to ...' messages |
ErrorCode.INTERNAL_ERROR |
src/domains/chapter-assignments/editor-state/user-chapter-assignment-editor-state.handlers.ts (2 sites)
| Message | Code |
|---|---|
'Failed to fetch editor state' |
ErrorCode.INTERNAL_ERROR |
'Failed to save editor state' |
ErrorCode.INTERNAL_ERROR |
src/domains/projects/projects.handlers.ts
- All
'Project not found'→ErrorCode.NOT_FOUND - All
'Failed to ...'→ErrorCode.INTERNAL_ERROR
Step 3 — Update route files (switch on code instead of string matching)
Each route file needs to add import { ErrorCode } from '@/lib/types';.
Replace all if (result.error.message === '...') and if (result.error.message.includes('...')) chains with switch (result.error.code) blocks.
Concrete before/after — project-users.route.ts (addProjectUser handler)
Before:
if (result.error.message === 'User not found') {
return c.json({ message: result.error.message }, HttpStatusCodes.NOT_FOUND);
}
if (result.error.message === 'User is already in this project') {
return c.json({ message: result.error.message }, HttpStatusCodes.BAD_REQUEST);
}
return c.json({ message: result.error.message }, HttpStatusCodes.INTERNAL_SERVER_ERROR);After:
switch (result.error.code) {
case ErrorCode.NOT_FOUND:
return c.json({ message: result.error.message }, HttpStatusCodes.NOT_FOUND);
case ErrorCode.DUPLICATE:
return c.json({ message: result.error.message }, HttpStatusCodes.BAD_REQUEST);
default:
return c.json({ message: result.error.message }, HttpStatusCodes.INTERNAL_SERVER_ERROR);
}Concrete before/after — project-users.route.ts (removeProjectUser handler)
Before:
if (result.error.message === 'User still has content assigned') {
return c.json({ message: result.error.message }, HttpStatusCodes.BAD_REQUEST);
}
if (result.error.message === 'User not found in project') {
return c.json({ message: result.error.message }, HttpStatusCodes.NOT_FOUND);
}
return c.json({ message: result.error.message }, HttpStatusCodes.INTERNAL_SERVER_ERROR);After:
switch (result.error.code) {
case ErrorCode.NOT_FOUND:
return c.json({ message: result.error.message }, HttpStatusCodes.NOT_FOUND);
case ErrorCode.CONFLICT:
return c.json({ message: result.error.message }, HttpStatusCodes.BAD_REQUEST);
default:
return c.json({ message: result.error.message }, HttpStatusCodes.INTERNAL_SERVER_ERROR);
}Concrete before/after — chapter-assignments.route.ts (submitChapterAssignment handler)
Before:
if (result.error.message === 'Chapter assignment not found') {
return c.json({ message: result.error.message }, HttpStatusCodes.NOT_FOUND);
}
if (result.error.message.includes('Cannot submit assignment')) {
return c.json({ message: result.error.message }, HttpStatusCodes.BAD_REQUEST);
}
return c.json({ message: result.error.message }, HttpStatusCodes.INTERNAL_SERVER_ERROR);After:
switch (result.error.code) {
case ErrorCode.NOT_FOUND:
return c.json({ message: result.error.message }, HttpStatusCodes.NOT_FOUND);
case ErrorCode.VALIDATION_ERROR:
return c.json({ message: result.error.message }, HttpStatusCodes.BAD_REQUEST);
default:
return c.json({ message: result.error.message }, HttpStatusCodes.INTERNAL_SERVER_ERROR);
}Concrete before/after — bible-books.route.ts (.includes pattern)
Before:
if (
result.error.message.includes('not found') ||
result.error.message.includes('No Bible Books found')
) {
return c.json({ message: result.error.message }, HttpStatusCodes.NOT_FOUND);
}
return c.json({ message: result.error.message }, HttpStatusCodes.INTERNAL_SERVER_ERROR);After:
switch (result.error.code) {
case ErrorCode.NOT_FOUND:
return c.json({ message: result.error.message }, HttpStatusCodes.NOT_FOUND);
default:
return c.json({ message: result.error.message }, HttpStatusCodes.INTERNAL_SERVER_ERROR);
}All affected route files (9 files, 20 dispatch sites)
src/domains/projects/project-users/project-users.route.ts— 4 sites (2 handlers × 2 error chains)src/domains/chapter-assignments/chapter-assignments.route.ts— 4 sites (3 exact===+ 1.includes)src/domains/projects/projects.route.ts— 3 sitessrc/domains/bibles/bibles.route.ts— 3 sitessrc/domains/languages/languages.route.ts— 1 sitesrc/domains/translated-verses/translated-verses.route.ts— 1 sitesrc/domains/bible-books/bible-books.route.ts— 1 site (.includes→switch)src/domains/bibles/bible-texts/bible-texts.route.ts— 1 site (.includes→switch)src/domains/books/books.route.ts— 2 sites (.includes→switch)
Step 4 — Verify
- Run
npx tsc --noEmitto confirm type safety - Grep for any remaining
result.error.message ===orresult.error.message.includesto confirm none were missed - Run existing tests via
npx vitest run
Execution Order
Steps 1 → 2 → 3 → 4, strictly sequential. Step 1 changes the type, step 2 makes handlers conform, step 3 makes routes consume the new shape, step 4 validates.