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 Relationships3.3.2 Labels or Instructions4.1.2 Name, Role, Value
Prerequisites
- TypeScript 5+ in strict mode; several of these patterns depend on
exactOptionalPropertyTypesbehaving sensibly. - React 18+ type definitions, which already model
AriaAttributesandAriaRole. - 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
neveron 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
anycast 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.AriaRolefor role props andReact.AriaAttributes['aria-current'](and friends) for state props, rather thanstring. - Narrow further where your component only supports a subset — a
Chipthat can beoptionorpresentationshould say exactly that. - Model tri-state attributes honestly:
aria-checkedisboolean | 'mixed', and typing itbooleanquietly removes indeterminate support. - Prefer domain types that map to ARIA at the boundary —
status: 'idle' | 'busy'in your API, converted toaria-busyinside 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.
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-labelledbyas 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-expandedmatches 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');
});
How to Verify
- Delete a required prop. Remove
titlefrom 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: stringandaria-*: stringprops. 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-checkedas boolean. Removes themixedstate, so an indeterminate checkbox becomes unrepresentable. - Casting to
anyduring 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.
Related guides
- Accessibility Linting in Editors & Builds — the parent guide covering the whole static-analysis layer.
- Configuring eslint-plugin-jsx-a11y for React — the rules that cover raw DOM elements.
- Component Testing with jest-axe — asserting the rendered result types cannot see.
- Semantic HTML vs ARIA in Component Trees — the underlying rules these types encode.