<p>Every changelog has that one release where the "Added" section is empty and the "Changed" section says something like "internal type safety." Most people scroll right past it. This is a post in defense of that release — because v0.9.11 is exactly that release, and the work behind it is some of the most valuable we've shipped.</p><p>No new commands. No new models you didn't already have. What changed is that we deleted 143 <code>any</code> types from <code>@elyracode/web-ui</code> and replaced every one with a type that actually describes the data. Zero behavior change. Here's why that matters, and how we did it, with real before-and-after from the diff.</p><h2>Why bother? <code>any</code> is a silent "trust me"</h2><p>In TypeScript, <code>any</code> is an escape hatch that says "stop checking, I know what I'm doing." The problem is that it doesn't just turn off checking for that one value — it spreads. Every property you read off an <code>any</code> is also <code>any</code>. Every function it flows into loses its guarantees. One <code>any</code> in the wrong place quietly disables the type checker across a whole call path.</p><p>For an embeddable UI package like <code>web-ui</code> — the one you drop into your own app to get an Elyra chat panel — that's a real cost. Consumers rely on our types to know what they're wiring up. An <code>any</code> is a place where the editor stops helping them and a refactor can break things without anyone noticing until runtime.</p><p>So the "why" is simple: <strong>143 places where the compiler had been told to look away, now look back.</strong> Let me show you what that actually looked like.</p><h2>How: four patterns that covered almost everything</h2><h3>1. Give the message protocol a real shape</h3><p>The biggest cluster lived in the sandbox runtime — the bridge that lets sandboxed code talk to the host page. Messages were flying around as <code>any</code>, so every handler was guessing.</p><p>We introduced a shared <code>runtime-messages</code> module with a discriminated union. Now a message <em>is</em> one of a known set of shapes:</p><pre><code class="language-ts">export type ArtifactOperationMessage =
  | { type: "artifact-operation"; action: "list" }
  | { type: "artifact-operation"; action: "get"; filename: string }
  | { type: "artifact-operation"; action: "delete"; filename: string }
  | { type: "artifact-operation"; action: "createOrUpdate";
      filename: string; content: string; mimeType?: string };
</code></pre><p>The payoff shows up in the handler. Before, <code>message.filename</code> was a hopeful guess. Now, after you check <code>message.action === "get"</code>, TypeScript <em>knows</em> <code>filename</code> exists — and would error if you tried to read it in the <code>"list"</code> branch where it doesn't. The compiler enforces the protocol you designed, instead of you remembering it.</p><h3>2. Narrow instead of asserting</h3><p>A common shortcut is to cast away a union you don't want to deal with. Here's the PDF text extraction, before:</p><pre><code class="language-ts">const pageText = textContent.items
  .map((item: any) =&gt; item.str)   // some items have .str, some don't
  .filter((str: string) =&gt; str.trim())
</code></pre><p>PDF.js returns a mix of text items and "marked content" items; only the former have <code>.str</code>. The <code>any</code> papered over that. The fix narrows instead:</p><pre><code class="language-ts">const pageText = textContent.items
  .map((item) =&gt; ("str" in item ? item.str : ""))
  .filter((str) =&gt; str.trim())
</code></pre><p>Same runtime behavior — but now the code is honest about the fact that not every item has a string, and the compiler verifies we handle both.</p><h3>3. Type the dynamic data once, at the edge</h3><p>Sometimes the data genuinely is loosely shaped — like the document tree from <code>docx-preview</code>, whose own types literally declare <code>WordDocument = any</code>. You can't avoid the dynamism, but you can contain it. We described the shape we actually walk:</p><pre><code class="language-ts">interface DocxElement {
  type?: string;
  text?: string;
  children?: DocxElement[];
}

function extractTextFromElement(element: DocxElement): string { ... }
</code></pre><p>One interface at the boundary, and the entire recursive walk below it is type-checked. The <code>any</code> stops at the door instead of leaking into every line.</p><h3>4. Stop lying in <code>catch</code></h3><p>These were everywhere:</p><pre><code class="language-ts">} catch (error: any) {
  this.error = error?.message || i18n("Failed to load PDF");
}
</code></pre><p><code>error: any</code> lets you reach for <code>.message</code> on something that might be a string, a number, or anything a thrown value can be. The honest version:</p><pre><code class="language-ts">} catch (error) {
  this.error = (error instanceof Error ? error.message : undefined)
    || i18n("Failed to load PDF");
}
</code></pre><p>Now <code>error</code> is <code>unknown</code>, and we only read <code>.message</code> after proving it's an <code>Error</code>. Slightly more typing; a lot more truthful.</p><h3>Bonus: the cast that wasn't needed</h3><p>My favorite kind of cleanup is the one where the <code>any</code> was hiding a <em>better</em> API the whole time. This tested a provider key:</p><pre><code class="language-ts">let model = getModel(provider as any, modelId);   // fighting the generics
// ...
const result = await complete(model, context, { apiKey, maxTokens } as any);
</code></pre><p>Two <code>any</code>s, both bypassing the type system to force a square peg through. It turned out there was a round hole:</p><pre><code class="language-ts">let model = getModels(provider as KnownProvider).find((m) =&gt; m.id === modelId);
// ...
const result = await complete(model, context, { apiKey, maxTokens });
</code></pre><p><code>getModels()</code> returns a typed array, <code>.find()</code> returns <code>Model | undefined</code>, and <code>complete</code>'s options already accept <code>apiKey</code> and <code>maxTokens</code> — so the second <code>as any</code> was pure superstition. Removing <code>any</code> didn't just satisfy the linter; it led us to simpler, more correct code. That happens more often than you'd think: an <code>any</code> is frequently a sign you're using an API the hard way.</p><h2>How we kept it honest</h2><p>The rule for this kind of work is the same as the rule for <code>any</code> itself: don't cheat. No swapping one escape hatch for another. So:</p><ul><li><p>Every change was verified with <code>npm run check</code> (Biome + <code>tsc</code>) — green on every commit.</p></li><li><p>No behavior changes. This was a types-only pass; the emitted JavaScript is effectively identical.</p></li><li><p>We didn't reach for <code>// biome-ignore</code> to make red squiggles disappear. The count of remaining <code>any</code> in <code>web-ui</code> is now <strong>zero</strong>, honestly earned.</p></li></ul><p>We also folded in a routine model-registry refresh while we were here (a few price updates plus some new image and GLM models), so the data stays current.</p><h2>The takeaway</h2><p>A release with an empty "Added" section isn't a slow week — sometimes it's the week you pay down the quiet debt that makes every <em>future</em> feature safer to build. 143 fewer places where the compiler was told to look away means 143 fewer places a refactor can silently break, and a <code>web-ui</code> package whose types now tell consumers the truth.</p><p>If you build on <code>@elyracode/web-ui</code>, you don't have to do anything. Just update, and enjoy an editor that helps you a little more than it did last week. That's the whole feature: nothing changed, and everything got a little more trustworthy.</p>