---
title: 'Error and Warning States'
source: 'https://academia.sh/en/courses/interface-fundamentals/error-and-warning-states'
course: 'Fundamentals of Interface Design'
language: en
updated: '2026-08-17T18:11:56+00:00'
license: 'CC BY-SA 4.0'
---

# Error and Warning States

Classifying errors by source and recoverability, computing the number of messages produced by the moment of validation, filtering out preventable errors, and placing the message with the proximity rule.

The previous lesson showed that emptiness is not an error: the system worked
correctly, and the result had not yet arrived. The states in which the system does not
work correctly, or in which the input the user gave cannot be accepted, require a
separate design.

The catalog interface has three separate examples: an invalid value was entered in the
ISBN field, the borrow request could not reach the server, the user's borrowing limit
is full. All three get called "error," but each has a different path to recovery. In
the first, the user fixes the input; in the second, they try again; in the third, there
is nothing they can do. They cannot be presented in the same box.

## Errors Are Split by Source and Recoverability

The classification rests on two questions: where did the error come from, and can the
user fix it?

- **Input error, recoverable.** The value the user gave was not accepted. The path to
  recovery is correcting the value. The message stands next to the field and says what
  is expected.
- **State error, recoverable.** The operation cannot proceed because the system's
  state does not allow it — the record is with someone else, the limit is full. The
  path to recovery is a different action: placing a reservation, waiting for the due
  date. The message stands next to the action and shows the alternative.
- **Transient system error, recoverable.** The request did not arrive, or it timed
  out. The path to recovery is trying again. The message stands next to the operation
  and includes a retry path.
- **Permanent system error, unrecoverable.** There is nothing the user can do. The
  message states the situation and points to a different path; it does not say "try
  again," because trying again will not help.

The practical value of this distinction is that it determines the content of the
message. In a recoverable error, the message has to carry the **path to recovery**;
without it, the message is not an error message to the user, only a notice of an
obstacle. In an unrecoverable error, suggesting a path to recovery is misleading.

A warning is separated from an error on this same axis: with a warning, the operation
can still proceed. "This record is at another branch" is a warning, because the user
can still place a reservation. "Your borrowing limit is full" is an error, because the
operation cannot proceed. Color choice follows this distinction, not the other way
around.

## The Moment of Validation Determines the Message Count

The most important decision for input errors is **when** validation runs. The same
validation rule produces a different number of messages depending on when it runs.

```js
// validation.mjs — the effect of validation timing on the number of error messages produced

// ISBN-13 checksum: the first 12 digits are summed with weights 1,3,1,3...; the check digit completes it.
function isbnValid(string) {
  if (!/^\d{13}$/.test(string)) return false;
  const b = [...string].map(Number);
  let sum = 0;
  for (let i = 0; i < 12; i++) sum += b[i] * (i % 2 === 0 ? 1 : 3);
  return (10 - (sum % 10)) % 10 === b[12];
}

// Is an input's invalidity certain before it is complete?
function certainlyInvalid(prefix) {
  return /[^0-9]/.test(prefix) || prefix.length > 13;
}

const SESSIONS = [
  { name: "correct typing",   target: "9789750718533" },
  { name: "wrong last digit", target: "9789750718534" },
  { name: "letter mixed in",  target: "97897507I8533" },
];

for (const o of SESSIONS) {
  const prefixes = Array.from({ length: o.target.length }, (_, i) => o.target.slice(0, i + 1));

  // 1) Validate on every keystroke
  const everyKeystroke = prefixes.filter((p) => !isbnValid(p)).length;
  // 2) Warn only on certain invalidity, wait otherwise
  const early = prefixes.filter((p) => certainlyInvalid(p)).length;
  // 3) Validate on leaving the field
  const onExit = isbnValid(o.target) ? 0 : 1;
  // 4) Validate on submit
  const onSubmit = onExit;

  console.log(
    `${o.name.padEnd(20)} every keystroke: ${String(everyKeystroke).padStart(2)}   on certain invalidity: ${String(early).padStart(2)}   ` +
      `on field exit: ${onExit}   on submit: ${onSubmit}   result: ${isbnValid(o.target) ? "valid" : "invalid"}`
  );
}

// At which character is certain invalidity detected?
console.log("\nsession              first certain-invalid prefix length  total length");
for (const o of SESSIONS) {
  const prefixes = Array.from({ length: o.target.length }, (_, i) => o.target.slice(0, i + 1));
  const i = prefixes.findIndex(certainlyInvalid);
  console.log(
    `${o.name.padEnd(20)} ${(i === -1 ? "none" : String(i + 1)).padStart(33)} ${String(o.target.length).padStart(15)}`
  );
}

// Are the catalog records' ISBN fields actually valid?
console.log("\nrecord isbn      valid");
for (const isbn of ["9789750718533", "9789944882101", "9786055829476"]) {
  console.log(`${isbn}   ${isbnValid(isbn)}`);
}
```

```
correct typing       every keystroke: 12   on certain invalidity:  0   on field exit: 0   on submit: 0   result: valid
wrong last digit     every keystroke: 13   on certain invalidity:  0   on field exit: 1   on submit: 1   result: invalid
letter mixed in      every keystroke: 13   on certain invalidity:  5   on field exit: 1   on submit: 1   result: invalid

session              first certain-invalid prefix length  total length
correct typing                                    none              13
wrong last digit                                  none              13
letter mixed in                                      9              13

record isbn      valid
9789750718533   true
9789944882101   true
9786055829476   true
```

The first row settles the decision on its own. The user typed the ISBN **correctly**,
and an interface that validates on every keystroke showed them twelve error messages.
None of them was wrong: every intermediate step really was an invalid ISBN. But none
of them was right either, because the user had not finished typing yet.

The rule that follows is: **an input is not validated before it is complete unless its
invalidity is certain.** Validating on field exit produces zero messages for correct
typing and one for incorrect typing; this is the behavior wanted.

The third row shows the exception to the rule. In the session with a letter mixed in,
the invalidity becomes certain at the ninth character; no character typed after that
can make the value valid. Withholding a certain invalidity until completion means
making the user type four more characters for nothing. The rule is therefore two-part:
uncertain invalidity waits, certain invalidity is reported immediately.

This distinction also shapes the form of the validation rule. A field's rule must be
able to answer not only "is this valid" but also "can this prefix still extend into a
valid value."

## The Best Error Message Is the One Never Shown

Some errors are not errors; they are formatting noise in the input, and the interface
can correct them silently.

```js
// recovery.mjs — filtering out preventable errors and placing the message

function isbnValid(string) {
  if (!/^\d{13}$/.test(string)) return false;
  const b = [...string].map(Number);
  let sum = 0;
  for (let i = 0; i < 12; i++) sum += b[i] * (i % 2 === 0 ? 1 : 3);
  return (10 - (sum % 10)) % 10 === b[12];
}
// Clean up formatting noise: spaces, hyphens, en dashes, separator dots
const normalize = (s) => s.replace(/[\s\-‐-―.]/g, "");

const INPUTS = [
  "9789750718533",
  "978-975-07-1853-3",
  " 9789944882101 ",
  "978 605 5829 47 6",
  "9789750718534",
  "97897507I8533",
];

console.log("input                    raw valid  normalized               valid  category");
let preventable = 0, real = 0;
for (const g of INPUTS) {
  const n = normalize(g);
  const raw = isbnValid(g), final = isbnValid(n);
  let category;
  if (raw) category = "clean";
  else if (final) { category = "PREVENTABLE"; preventable++; }
  else { category = "real error"; real++; }
  console.log(
    `${JSON.stringify(g).padEnd(24)} ${String(raw).padStart(9)}  ${n.padEnd(24)} ${String(final).padStart(5)}  ${category}`
  );
}
console.log(`\npreventable errors: ${preventable} / ${INPUTS.length}   real errors: ${real} / ${INPUTS.length}`);

// Message placement: proximity rule (within-group gap < between-group gap)
const LABEL_Y = 20, FIELD_Y = 44, MESSAGE_Y = 18;
const WITHIN_GROUP = 4;   // label-to-field and field-to-message gap
const BETWEEN_GROUPS = 24; // gap between two field groups

const fields = ["Title", "Author", "ISBN", "Shelf Code"];
let y = 0;
const position = {};
for (const a of fields) {
  position[a] = { label: y, field: y + LABEL_Y + WITHIN_GROUP };
  y = position[a].field + FIELD_Y + BETWEEN_GROUPS;
}
console.log("\nfield      label y  field y  field bottom edge");
for (const a of fields) {
  console.log(`${a.padEnd(10)} ${String(position[a].label).padStart(8)} ${String(position[a].field).padStart(7)} ${String(position[a].field + FIELD_Y).padStart(16)}`);
}

const errorField = "ISBN";
const bottomEdge = position[errorField].field + FIELD_Y;
const nextLabel = position["Shelf Code"].label;
console.log(`\nif the message is placed below the field:`);
console.log(`  distance to message (from its own field): ${WITHIN_GROUP} px`);
console.log(`  message bottom edge: ${bottomEdge + WITHIN_GROUP + MESSAGE_Y} px`);
console.log(`  distance to next field's label: ${nextLabel - (bottomEdge + WITHIN_GROUP + MESSAGE_Y)} px`);
console.log(`  proximity rule (closer to its own field): ${WITHIN_GROUP < nextLabel - (bottomEdge + WITHIN_GROUP + MESSAGE_Y)}`);

console.log(`\nif the message is placed as a summary above the form:`);
console.log(`  vertical distance from summary to error field: ${position[errorField].field} px`);
console.log(`  fields in between: ${fields.indexOf(errorField)}`);

// If the proximity rule is not met: what must the between-group gap be at minimum?
const FACTOR = 3; // the message must be at least this many times closer to its own field than to the next group
const requiredGap = WITHIN_GROUP + MESSAGE_Y + FACTOR * WITHIN_GROUP;
console.log(`\nspace occupied below the field including the message block: ${WITHIN_GROUP + MESSAGE_Y} px`);
console.log(`minimum gap between groups: ${requiredGap} px  (current: ${BETWEEN_GROUPS} px)`);
console.log(`distance to next label at the new gap: ${requiredGap - WITHIN_GROUP - MESSAGE_Y} px, to its own field ${WITHIN_GROUP} px, ratio ${(requiredGap - WITHIN_GROUP - MESSAGE_Y) / WITHIN_GROUP}`);

// Do the following fields shift when the message appears? If space is reserved, they do not shift.
const shift = WITHIN_GROUP + MESSAGE_Y;
console.log(`\nif no space is reserved for the message, every following field shifts down by ${shift} px`);
console.log(`total shifting fields in a four-field form (ISBN in error): ${fields.length - 1 - fields.indexOf("ISBN")}`);
console.log(`if space is reserved, shift: 0 px, cost: a constant ${shift} px of empty space below every field`);
```

```
input                    raw valid  normalized               valid  category
"9789750718533"               true  9789750718533             true  clean
"978-975-07-1853-3"          false  9789750718533             true  PREVENTABLE
" 9789944882101 "            false  9789944882101             true  PREVENTABLE
"978 605 5829 47 6"          false  9786055829476             true  PREVENTABLE
"9789750718534"              false  9789750718534            false  real error
"97897507I8533"              false  97897507I8533            false  real error

preventable errors: 3 / 6   real errors: 2 / 6

field      label y  field y  field bottom edge
Title             0      24               68
Author           92     116              160
ISBN            184     208              252
Shelf Code      276     300              344

if the message is placed below the field:
  distance to message (from its own field): 4 px
  message bottom edge: 274 px
  distance to next field's label: 2 px
  proximity rule (closer to its own field): false

if the message is placed as a summary above the form:
  vertical distance from summary to error field: 208 px
  fields in between: 2

space occupied below the field including the message block: 22 px
minimum gap between groups: 34 px  (current: 24 px)
distance to next label at the new gap: 12 px, to its own field 4 px, ratio 3

if no space is reserved for the message, every following field shifts down by 22 px
total shifting fields in a four-field form (ISBN in error): 1
if space is reserved, shift: 0 px, cost: a constant 22 px of empty space below every field
```

Three of the six inputs look invalid only because they carry separators. Once the
separators are cleaned up, all three are valid. Showing an error message for these
three inputs does not give the user a problem they can solve; it rejects input that
was already correct.

The rule reads as follows: **no error is shown for an input the interface can
accept.** Separators, leading and trailing spaces, and case differences fall into this
class. The limit of silent correction is that it must not change meaning; correcting a
digit cannot be done silently, because which digit is wrong is not known.

## The Message's Position Is Checked by the Proximity Rule

The second group of tables measures where the message should go and finds a flaw in
the form layout.

When the message is placed 4 pixels below the field, its bottom edge lands at 274
pixels, and the next field's label starts at 276 pixels. The gap between them is 2
pixels. The rule established in the Proximity and Grouping lesson is violated here: the
message is 4 pixels from the field it belongs to and 2 pixels from the field it does
not. Read on screen, the message appears to belong to the next field.

The correction can be computed. The message block occupies 22 pixels below the field;
requiring the message to sit at least three times closer to its own field pushes the
between-group gap up to 34 pixels. At the new gap, the message sits 4 pixels from its
own field and 12 pixels from the next label.

A summary above the form does not satisfy the proximity rule at all: there are 208
pixels and two fields between the summary and the field in error. This does not mean
the summary is wrong; the summary does a different job — it reports how many errors
there are and gives a link to each. But the summary does **not take the place** of the
message next to the field. The two are used together: the summary at the top of the
page, the message next to the field.

The final block shows a side effect. When the message appears, the fields below it
shift down 22 pixels; this is the small-scale version of the layout shift problem from
the previous lesson. While the user reads the message, the field they meant to click
moves. The solution is to reserve space for the message from the start; the cost is a
constant 22 pixels of empty space below every field. This cost is paid, because in a
shifting form, clicking the wrong field produces a new error.

## Summary

- Errors are split by source and recoverability; the class determines the message's
  content and position.
- In a recoverable error, the message has to carry the path to recovery; in an
  unrecoverable error, suggesting a path to recovery is misleading.
- A warning and an error are split by whether the operation can still proceed; color
  choice follows this distinction.
- An incomplete input is not validated until its invalidity is certain; validating on
  every keystroke produces dozens of messages even for correct typing. Once invalidity
  becomes certain, it is reported immediately; the validation rule must be able to
  answer "can this prefix still extend into a valid value."
- No error is shown for an input the interface can accept; formatting noise is
  corrected silently, and no correction that changes meaning is made.
- The message's position is checked by the proximity rule, and space for the message
  is reserved from the start.

## Next Step

Using an icon next to an error message is a common decision, and this lesson did not
justify that decision. The Functional Colors lesson counted the icon as the second
channel accompanying color, but the question of when the icon itself carries meaning
and when it merely takes up space stayed open. The next lesson splits icons into
semantic and decorative, shows what the distinction means in the accessibility tree,
and discusses whether an icon can take the place of text on its own.
