testing and automating accessibility

Catching ARIA Mistakes with TypeScript and ESLint

ESLint reacts to mistakes; TypeScript can prevent them. That distinction matters most for ARIA, where the common bugs are not typos but omissions — a dialog with no accessible name, an aria-describedby pointing at an id nobody rendered, a toggle button whose state prop was never wired up. A linter cannot see any of those. A well-designed prop type can make several of them impossible to write.

This guide covers where types help, where they cannot, and how to combine them with the rules from configuring eslint-plugin-jsx-a11y for React.

WCAG Success Criteria Addressed (by prevention):

  • 1.3.1 Info and Relationships
  • 3.3.2 Labels or Instructions
  • 4.1.2 Name, Role, Value
What each tool can and cannot catch Three overlapping areas of coverage. TypeScript catches missing required props, invalid role and state values, and unwired id associations at compile time. ESLint catches raw DOM mistakes such as a div with a click handler, invented aria attributes, and missing alt attributes. A rendered-DOM scan catches everything that only exists at runtime: computed names, duplicate ids across a page, contrast, and focus order. A note marks the region only a human can judge, whether the name is meaningful. TypeScript required props missing invalid role values state props unwired id associations forgotten at compile time ESLint div with a click handler invented aria-* names missing alt attributes links used as buttons on save Rendered DOM scan computed accessible name duplicate ids on a page contrast and focus order state that never updates in a test run Outside all three: whether the name is meaningful, and whether the flow makes sense That judgement stays human, which is exactly why the mechanical parts should be automated.

Prerequisites

  • TypeScript 5+ in strict mode; several of these patterns depend on exactOptionalPropertyTypes behaving sensibly.
  • React 18+ type definitions, which already model AriaAttributes and AriaRole.
  • A component library you control. These techniques constrain your own APIs; they cannot constrain raw DOM elements, which is ESLint's job.

Make the Nameless Case Unrepresentable

The most valuable type in an accessible design system is a union that forbids a control with no name. It converts an audit finding into a compile error.

Implementation Guidelines:

  • Model "has visible text" and "has an aria-label" as two variants with never on the opposite property, so both cannot be supplied and neither can be omitted.
  • Apply it to every component that can render without text: buttons, links, icon-only controls, images.
  • Keep the error message readable by naming the variants; a raw union error is cryptic, and a JSDoc comment on the type shows up in the editor.
  • Do not widen the type "temporarily" to unblock a migration — an any cast in one call site removes the guarantee everywhere.
/** A control must have visible text OR an explicit label — never neither. */
type WithText = { children: React.ReactNode; 'aria-label'?: never };
type WithLabel = { children?: never; 'aria-label': string };

export type ButtonProps = (WithText | WithLabel) &
  Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'aria-label'>;

// <Button />                       ✗ compile error — no name
// <Button>Save</Button>            ✓
// <Button aria-label="Close" />    ✓
// <Button aria-label="Close">Х</Button>  ✗ two names

Type Roles and States Instead of Strings

role: string accepts "buton". React's own types already model the valid set, and using them costs nothing.

Implementation Guidelines:

  • Use React.AriaRole for role props and React.AriaAttributes['aria-current'] (and friends) for state props, rather than string.
  • Narrow further where your component only supports a subset — a Chip that can be option or presentation should say exactly that.
  • Model tri-state attributes honestly: aria-checked is boolean | 'mixed', and typing it boolean quietly removes indeterminate support.
  • Prefer domain types that map to ARIA at the boundary — status: 'idle' | 'busy' in your API, converted to aria-busy inside the component.
type MenuItemProps = {
  role: Extract<React.AriaRole, 'menuitem' | 'menuitemcheckbox' | 'menuitemradio'>;
  // 'mixed' is a real state; typing this boolean would silently drop it.
  checked?: boolean | 'mixed';
};

Make id Associations Impossible to Forget

aria-labelledby and aria-describedby are the ARIA attributes most likely to point at nothing, because nothing enforces that the referenced element exists.

Two ways to wire aria-labelledby, one of which can break On the left, the caller passes a raw string id to aria-labelledby and separately renders a heading with that id; if the heading is later removed or renamed, the reference silently points at nothing and the dialog loses its name. On the right, the component generates the id itself with useId and requires the heading as a titled prop, so the reference and the element are created together and cannot drift apart. Caller supplies the id <Dialog aria-labelledby="dlg-title">   <h2 id="dlg-title">Edit profile</h2> Two independent strings that must agree forever. Rename the heading → the dialog silently loses its name. Component owns the id <Dialog title="Edit profile">   const id = useId() inside The heading and the reference are produced by the same code path. The title prop is required, so a nameless dialog will not compile. Every id you ask a caller to supply is a relationship you are asking them to maintain by hand.

Implementation Guidelines:

  • Generate ids inside the component with useId() and take the content as a prop instead of the id.
  • Where a caller genuinely must supply the relationship — a description rendered elsewhere on the page — require the content and the id together as one object, so neither can arrive alone.
  • Never accept aria-labelledby as a bare optional string on a component that requires a name; make the name the required prop.
  • For descriptions that are sometimes absent, model the absence explicitly rather than allowing an empty string that produces an empty reference.
type DialogProps = {
  /** Rendered as the dialog's heading and used as its accessible name. */
  title: string;
  children: React.ReactNode;
};

export function Dialog({ title, children }: DialogProps) {
  const id = useId();
  return (
    <div role="dialog" aria-modal="true" aria-labelledby={id}>
      <h2 id={id}>{title}</h2>
      {children}
    </div>
  );
}

Where Types Stop

Being honest about the boundary keeps the effort proportionate. Types describe the shape of a call, not the behaviour of a render.

Implementation Guidelines:

  • Accept that a type cannot check whether aria-expanded matches what is on screen — that needs a rendered-DOM test.
  • Accept that it cannot detect duplicate ids across a page, because each component compiles in isolation.
  • Accept that it cannot judge whether "Close" is the right name for a button that actually saves. Only a human can.
  • Do not build elaborate type gymnastics to approach those cases. A test that renders the component and asserts the accessible name is simpler and catches more.
// The assertion a type cannot make: the state matches the rendered UI.
test('the disclosure reports its expanded state', async () => {
  render(<NavDisclosure label="Guides">…</NavDisclosure>);
  const trigger = screen.getByRole('button', { name: 'Guides' });
  expect(trigger).toHaveAttribute('aria-expanded', 'false');
  await userEvent.click(trigger);
  expect(trigger).toHaveAttribute('aria-expanded', 'true');
});
Four ARIA bugs and the tool that actually catches each Four rows pairing a bug with the tool that detects it. A dialog rendered with no name is caught by a required prop at compile time. An invented attribute such as aria-labeled-by is caught by an ESLint rule. An aria-expanded value that never updates is caught only by a rendered-DOM test. A button named Close that actually saves is caught only by a human reviewer. Dialog rendered with no accessible name required prop — compile error aria-labeled-by (invented attribute) ESLint aria-props — on save aria-expanded that never changes rendered-DOM test — in CI A button named "Close" that saves a human — in review

How to Verify

  • Delete a required prop. Remove title from a <Dialog> call and confirm the build fails. If it compiles, the contract is not enforced.
  • Try both name variants. Render a button with children and an aria-label; the union should reject it.
  • Check a subset type. Pass role="button" to a component typed for menu roles and confirm the error names the allowed values.
  • Run the DOM tests. Assert the computed accessible name with Testing Library's getByRole(..., { name }), which uses the same algorithm browsers do.
  • Scan the rendered output. Run jest-axe over the component to catch what neither types nor lint can — see testing React components with jest-axe.

Common Accessibility Mistakes

  • role: string and aria-*: string props. Every typo compiles, and ARIA fails silently rather than throwing.
  • Optional name props. "Optional" means "omitted in a hurry"; if the component needs a name, require it.
  • Accepting an id instead of content. The caller now maintains a relationship between two files that nothing checks.
  • Typing aria-checked as boolean. Removes the mixed state, so an indeterminate checkbox becomes unrepresentable.
  • Casting to any during a migration. One cast removes the guarantee for every call site downstream of it.
  • Believing a green type-check is an accessibility pass. Types cover shape; behaviour and meaning still need tests and people.

Conclusion

Types and lint rules cover different halves of the same problem. ESLint watches the raw DOM you write directly; TypeScript constrains the components you build on top of it, and it is uniquely good at the omissions ARIA suffers from — the missing name, the unwired state, the dangling id reference. Model the nameless case as unrepresentable, type roles and states from React's own definitions, and let components own the ids they reference. Then let rendered-DOM tests handle everything that only exists at runtime, and keep human judgement for the question no tool answers: is this name actually right?

Frequently Asked Questions

Does this work in a JavaScript codebase? Partly. JSDoc type annotations with checkJs give you most of the union-type benefit without migrating to TypeScript. Where that is not available, PropTypes can require a prop at runtime in development — weaker, later feedback, but better than nothing.

Are these unions painful to use? The button union is not: call sites either pass children or a label, which is what people write anyway. Unions get painful when they encode more than two or three variants, so keep them shallow. If a component needs a five-way union to be safe, it is probably two components.

Should every component require a name? No — only those that can render without visible text. A Card with a heading inside it gets its name from content; forcing a label prop there produces noise and encourages people to pass junk to satisfy the compiler.

What about ARIA on third-party components? Types cannot help you there; you are constrained by whatever the library exposes. Audit the rendered output instead, as described in auditing a third-party React component for accessibility, and wrap the component if it needs attributes it does not accept.

How do I migrate an existing component to a required-name union? Change the type, run the compiler, and fix the call sites it lists — the errors are a complete inventory of every unnamed control in the codebase, which is more reliable than any audit. Where a call site genuinely cannot supply a name yet, add a temporary aria-label with a TODO and a ticket rather than widening the type; the widened type would hide every future case as well.

Can I generate types from the ARIA specification? React's type definitions already track it closely, and hand-narrowed subsets are more useful than exhaustive generated unions. The value is not in modelling every attribute — it is in constraining the handful your components actually use.