Accessibility, i18n & TypeScript at scale

Three "invisible-until-they-break" correctness disciplines. None of them shows up in a demo — all three blow up at the platform scale, across many locales and many teams. The Lead move is to encode them once into the platform.

1 One frame: three correctness guarantees

Don't treat these as three unrelated chores. They're the same kind of problem: quality that is invisible in the happy-path demo but mandatory at scale. Each one answers "is the UI still correct for…":

AccessibilityCorrect for every body — every input modality (keyboard, screen reader, switch, touch), not just a sighted mouse user.
InternationalizationCorrect in every locale — every language, script direction, number/date/currency format and plural rule, not just English.
TypeScript at scaleCorrect after every refactor — contracts hold across teams and time, so a change in one package can't silently break a consumer.
One-liner

These aren't features you sprinkle on — they're guarantees you build into the platform. Right for every body, every locale, every refactor. Encode it once so 90 engineers don't each re-derive it.

2 Internationalization — the the platform heavyweight

For a travel platform this is the biggest of the three. The single most important mental shift: i18n is not "translate the strings." It's "stop hard-coding assumptions about language, format, and direction into the UI." Translation is one slice; formatting, pluralization, direction, and routing are the rest.

Use the browser's native Intl API — don't reinvent locale data

Every browser ships a full Unicode/CLDR locale database behind the Intl namespace. Never format numbers, dates, or currency by hand — you will get it wrong for some locale. The pattern is always the same: construct a formatter with a locale + options, then call it.

// Price: ₿ vs $ vs € — symbol, decimal/grouping separators, position all differ
new Intl.NumberFormat('de-DE', {style:'currency', currency:'EUR'}).format(1234.5)
// → "1.234,50 €"   (German: dot groups, comma decimal, symbol after)
Intl.NumberFormat('en-US', {style:'currency', currency:'USD'}).format(1234.5)
// → "$1,234.50"

// Dates, relative time, lists, plural category — all locale-aware, zero deps
new Intl.DateTimeFormat('ja-JP', {dateStyle:'long'}).format(new Date())  // 2026年6月13日
new Intl.PluralRules('ru').select(2)   // "few"  — Russian has 4 plural forms, not 2
Never build sentences by concatenation. count + " hotels" assumes English word order and a single plural rule. Word order differs by language, and plural categories vary — Arabic has six, Russian four, Japanese one. The fix is ICU MessageFormat: a translatable template ({count, plural, one {# hotel} other {# hotels}}) where each locale's translators define their own plural branches. Your job is to pass the variable; theirs is to phrase it.

Direction (RTL), segmentation, and the "don't bake it in" rule

Routing + SEO: locale lives in the URL

This is where i18n meets the rendering/SEO work from earlier lessons. Each locale must be a crawlable URL (/en/…, /th/… or a per-country domain), rendered server-side, with hreflang alternate tags so Google serves the right language version and doesn't treat them as duplicate content. Lazy-load only the active locale's message bundle — never ship all 40 languages to every user.

One-liner

i18n isn't translating strings — it's removing English assumptions. Use the native Intl API for format, ICU MessageFormat for plurals, logical CSS for direction, and a locale-in-URL + hreflang for SEO.

3 Accessibility — semantic HTML first, ARIA last

The single highest-leverage idea: most accessibility is free if you use the right HTML element. A real <button> is focusable, keyboard-operable, and announced as a button by every screen reader — automatically. A <div onClick> is none of those and forces you to re-implement all of it (tabindex, role, keydown handlers) by hand, badly.

The hierarchy of fixes

Apply in this order

1. Semantic HTML (button, a, nav, main, label, headings) → 2. native attributes (alt, for) → 3. ARIA only to fill genuine gaps.

"No ARIA is better than bad ARIA"

Common senior trap

ARIA only changes how AT announces an element — it adds zero behavior. role="button" on a div doesn't make it focusable or clickable by keyboard. Wrong ARIA actively misleads screen-reader users. (W3C ARIA APG)

The four things interviewers actually probe

Testing: automated catches ~a third, humans catch the rest

Wire axe-core (via jest-axe / Playwright / Storybook a11y addon) into CI — it catches programmatic issues (missing alt, contrast, bad ARIA) automatically. But automation can't tell you if the tab order makes sense or the screen-reader narrative is coherent — that needs manual keyboard + VoiceOver/NVDA passes. Treat axe as the floor, not the ceiling.

Accessibility now has legal teeth, not just goodwill. The EU European Accessibility Act came into force 28 June 2025, requiring WCAG-level accessibility for e-commerce and travel services sold in the EU (WCAG 2.2 AA is the de-facto bar). For a global booking platform that's a compliance risk, not a nice-to-have — which is exactly why a Lead bakes it into the design system and CI rather than leaving it to each team.
One-liner

Reach for the right element before reaching for ARIA. No ARIA beats bad ARIA. Automated axe is the floor; a keyboard-and-screen-reader pass is the real test.

4 TypeScript at scale — types as contracts

At Staff level the question is never "do you use TypeScript." It's "how do you keep types trustworthy across many packages, many teams, and a runtime that erases them." Three ideas matter.

1. Strict mode, and treat any as a hole in the contract

Without "strict": true (especially strictNullChecks), TypeScript misses the bug class that actually crashes production — the undefined is not a function family. Every any is a place where the type system silently switches off; prefer unknown + narrowing, and lint any to a warning. (tsconfig strict)

2. Types end at runtime — validate at trust boundaries

The most common senior misconception: that a TypeScript interface protects you from a bad API response. It does not — types are erased at compile time; at runtime an as User cast is a lie the compiler can't check. At every boundary where data enters your app from outside (network, localStorage, URL params, postMessage), validate it at runtime with a schema library like Zod, then derive the type from the schema so there's one source of truth.

import { z } from 'zod';
const Hotel = z.object({ id: z.string(), price: z.number(), name: z.string() });
type Hotel = z.infer<typeof Hotel>;          // type derived FROM the validator

const res = await fetch('/api/hotels/1');
const hotel = Hotel.parse(await res.json());  // throws if the API lied — caught HERE, not 3 components deep

3. Share types as a contract, and scale the build with project references

Across teams (and micro-frontends — see L14), the API contract between producer and consumer should live in a shared, versioned types package, not be copy-pasted. This is the direct fix for the "lost types across the Module Federation boundary" cost from L14. To keep the typechecker fast as the monorepo grows, use TypeScript project references: they enforce explicit project boundaries (you can't import a package you didn't declare) and enable incremental builds so CI only re-checks affected projects. (Nx on project references)

Type-safety is only as strong as your boundaries. A strict codebase with as any casts at the network edge gives false confidence — green CI, runtime crashes. The Lead-level position: strict mode catches refactor bugs inside the trusted core; runtime validation (Zod) guards the edges where data is untrusted. You need both — they cover different threats.
One-liner

TypeScript protects you from your own refactors, not from the network. Strict mode inside the trusted core, runtime validation at every boundary, shared types as the cross-team contract.

5 The Lead synthesis: encode it once

The thread through all three: a single engineer can try to be accessible, localized, and type-safe — but at the platform scale, "try" loses. The Lead answer is to make the correct path the default and the incorrect path hard, by pushing each guarantee down into shared infrastructure:

GuaranteeDon't leave it to each team — encode it in…
AccessibilityA design system of accessible-by-default components (headless cores like Radix/React Aria — see L15) + axe + visual-regression gates in CI. Teams compose correct primitives instead of re-rolling a div-button.
i18nA shared <FormattedMessage>/t() layer over Intl + ICU, an extraction pipeline to the translation vendor, and a lint rule that fails the build on a hard-coded string.
TypeScriptA strict shared tsconfig, a published types/contracts package, project references, and Zod schemas at every API boundary — checked in CI.
One-liner

You can't review your way to quality at this scale — you have to build it into the platform. Make the accessible, localized, type-safe path the easy default, and the wrong path fail CI.

6Check yourself — scenario quiz

0 / 8 correct

1. A teammate writes `${count} hotels found` and sends it to translators. Why will this break in production?

2. You need to display a price to users in Germany, Japan, and the US. What's the right approach?

3. A developer ships a custom dropdown built as <div role="button" onClick={...}>. A screen-reader user reports they can't open it with the keyboard. What's the core lesson?

4. Your SPA passes axe-core with zero violations, but blind users say the app is confusing to navigate. What's the most likely gap?

Automated checks are green across the whole app.

5. An API sometimes returns price: null. The code does const hotel = await res.json() as Hotel and later crashes on hotel.price.toFixed(2). The interface says price: number. Why didn't TypeScript catch it?

6. Supporting Arabic. Beyond translating text, what's the key UI concern, and the modern way to handle it?

7. Two micro-frontend teams keep breaking each other when the hotel API shape changes — the consumer only finds out at runtime, in production. What's the structural fix?

8. You're the FE Lead. With 90 engineers across many teams, how do you actually guarantee accessibility and localization — not just hope for them?

Out-loud drill — do this before your interview

In 90 seconds: "How would you make the hotel-card component correct for every user and every market?" Hit one point from each axis — a11y (semantic element + focus/target size), i18n (Intl currency + ICU plural + RTL logical props + hreflang URL), TS (strict + Zod at the price API boundary) — and finish with the Lead move: encode it in the design system + CI, don't leave it to each team.

Good follow-up topics:

How does locale routing interact with CDN caching? What's the focus-management pattern for SPA route changes? Zod vs Valibot vs io-ts — how do I choose? When is ARIA actually the right tool? How do project references differ from path aliases? How do I budget for translation bundle size across 40 locales? What does an i18n string-extraction pipeline look like? How do I test RTL in CI?