Every server action in our quiz engine returns instead of throwing:
/**
* Shared error codes for the Quiz Hosting Engine.
*
* When a mutation fails, the `error` field is one of these string literals,
* enabling exhaustive TypeScript error handling in components.
*/
export const GameError = {
INVALID_INPUT: 'invalid_input',
SESSION_ALREADY_ACTIVE: 'session_already_active',
SESSION_LIMIT_REACHED: 'session_limit_reached',
DAILY_LIMIT_REACHED: 'daily_limit_reached',
TABLE_LIMIT_REACHED: 'table_limit_reached',
INVALID_TRANSITION: 'invalid_transition',
SESSION_NOT_FOUND: 'session_not_found',
LATE_JOIN_DISABLED: 'late_join_disabled',
TABLE_ALREADY_JOINED: 'table_already_joined',
NAME_TAKEN: 'name_taken',
RATE_LIMITED: 'rate_limited',
SERVER_ERROR: 'server_error',
INVALID_NAME: 'invalid_name',
INVALID_ANSWER: 'invalid_answer',
INVALID_PARTICIPANT: 'invalid_participant',
DUPLICATE_ANSWER: 'duplicate_answer',
QUESTION_CLOSED: 'question_closed',
INVALID_SCORING_MODE: 'invalid_scoring_mode',
UNAUTHORIZED: 'unauthorized',
} as const
export type ErrorCode = typeof GameError[keyof typeof GameError]
Nineteen codes, an as const, a derived union type, and a stated goal of exhaustive handling. I like this file. The reason I am writing about it is that almost none of that goal survives contact with the components, and tracing why was more interesting than fixing it.
The three translators
Here is the player's one, which runs when a phone fails to join a quiz:
function joinErrorMessage(error: string, teamMode: boolean): string {
switch (error) {
case 'invalid_name':
return `Please enter a ${teamMode ? 'team name' : 'name'} between 1 and 30 characters.`
case 'name_taken':
return `That ${teamMode ? 'team name' : 'name'} is already taken in this quiz. Try another.`
case 'late_join_disabled':
return 'The quiz has started and late joining is disabled. Wait for the next round.'
case 'table_already_joined':
return 'This table has already joined. To rejoin on a new phone, enter your team name exactly as before.'
case 'session_not_found':
return 'Session not found, it may have ended. Wait for the next one.'
case 'rate_limited':
return 'Lots of people are joining right now. Wait a few seconds and try again.'
default:
return 'An unexpected error occurred. Please try again.'
}
}
Six cases, good copy, and error: string. Not ErrorCode. The union exists, it is exported, the action that produced the value declares it precisely, and the function signature at the boundary throws all of that away. Add a code tomorrow and this switch compiles happily and sends the player to default.
The host's one does the same thing and takes a different view of what the fallback should say:
function errorText(error: string): string {
switch (error) {
case 'rate_limited': return 'Too many requests, try again in a moment.'
case 'unauthorized': return 'Your login has expired. Refresh the page and sign in again.'
case 'session_not_found': return 'This session no longer exists.'
default: return `Something went wrong (${error}).`
}
}
That default prints the raw code into the interface. A host who hits an unmapped failure mid-quiz reads "Something went wrong (invalid_transition)." Which, honestly, I would rather have than "An unexpected error occurred" if I were the one standing in front of a room holding a microphone, because it is a string I can send in a support email. It is a deliberate asymmetry: players get a sentence, the person running the night gets a token.
The third translator is not a function at all. It is an if-else ladder inside the modal that starts a quiz:
if (result.error === 'session_already_active') {
setError('You already have an active session. End it before starting a new one.')
} else if (result.error === 'session_limit_reached') {
setError('You have reached the maximum number of simultaneous sessions for your plan.')
} else if (result.error === 'daily_limit_reached') {
setError('You\'ve reached your daily quiz limit. Upgrade to Pro for more sessions, or try again tomorrow.')
} else if (result.error === 'invalid_input') {
setError('Invalid session settings or the selected pack has no questions.')
} else {
setError('Failed to start session. Please try again.')
}
Three shapes for one job, in three files, by the same person, within a few weeks.
The cost is not the inconsistency, it is what a static string cannot say
Look at the daily limit message again: "Upgrade to Pro for more sessions."
A trial account gets one quiz a day, so that sentence is right for them. A Pro account gets five a day, and when a Pro host starts their sixth quiz they are told to upgrade to Pro. The code daily_limit_reached carries no number and no plan name, so the only honest thing the component can write is something vague, and what got written instead was something specific and sometimes wrong.
Compare it with the place in our app that does not use codes at all. Adding tables returns a message, not an error code, and composes it from the limit it just enforced:
message: `You've reached the ${limits.maxTables}-table limit on your ${planType === 'pro' ? 'Pro' : 'Trial'} plan. Upgrade to Ultimate for unlimited tables.`
That sentence cannot drift, cannot name the wrong plan, and tells the host the actual number. It is also untypeable, unmatchable and impossible to branch on, which is exactly what the codes were introduced for.
The real answer is a code plus a payload: { error: 'daily_limit_reached', limit: 5, plan: 'pro' }. We have not done it, and the reason is that it costs a shape change in every action and every call site to fix a sentence in one modal, so it keeps losing to whatever is on fire.
The code nothing has ever returned
TABLE_LIMIT_REACHED appears in exactly one place in the repository: its own definition. The feature it describes exists and is enforced, but the action that enforces it predates the taxonomy and returns the prose message above instead.
So the list reads as a specification of nineteen things that can go wrong, and one of them is an announcement about a return value that does not exist. The next person to write a table action and reach for the obvious code would be the first to use it, which is probably how it was meant to work, except they would then have to add a nineteenth case to a translator that takes a string and defaults happily.
A related detail: our tests reference these codes as bare strings too. The integration test does it six times, in lines like expect(late.error).toBe('late_join_disabled') and expect(second.error).toBe('duplicate_answer'), rather than going through GameError. Renaming a code's value would leave both the component switch and the test green while every real failure fell through to the default branch.
What I would actually change
Two small things, in order of value.
First, type the translators. function joinErrorMessage(error: ErrorCode, teamMode: boolean) plus a satisfies Record<ErrorCode, string> map where the copy is plain, and the compiler starts telling us about the codes we forgot. That is a half hour and it converts a silent fallback into a build failure.
Second, stop writing numbers into copy that cannot see them. If a limit message wants to name the limit, the limit has to travel with the error. Until then, the honest sentence is "you have reached your plan's daily limit", with the number left to the page whose job is numbers.
The failures players actually hit are the interesting half of this list, and most of them have an answer in plain English on our FAQ: what happens when a phone will not scan the code, what happens when the WiFi drops in the middle of a question, and whether a team can join after the first round. The joining path those codes come out of is described on the QR joining page, and the host side, including who controls when a question closes, is in the hosting guide.
Top comments (0)