Skip to content

Replace String-Based Error Dispatching with Error Codes #70

@kaseywright

Description

@kaseywright

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)

  1. src/domains/projects/project-users/project-users.route.ts — 4 sites (2 handlers × 2 error chains)
  2. src/domains/chapter-assignments/chapter-assignments.route.ts — 4 sites (3 exact === + 1 .includes)
  3. src/domains/projects/projects.route.ts — 3 sites
  4. src/domains/bibles/bibles.route.ts — 3 sites
  5. src/domains/languages/languages.route.ts — 1 site
  6. src/domains/translated-verses/translated-verses.route.ts — 1 site
  7. src/domains/bible-books/bible-books.route.ts — 1 site (.includesswitch)
  8. src/domains/bibles/bible-texts/bible-texts.route.ts — 1 site (.includesswitch)
  9. src/domains/books/books.route.ts — 2 sites (.includesswitch)

Step 4 — Verify

  • Run npx tsc --noEmit to confirm type safety
  • Grep for any remaining result.error.message === or result.error.message.includes to 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.

Metadata

Metadata

Assignees

Type

Projects

Status

Dev Ready

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions