NoWaterProgramming

TypeScript Secure Coding, Measured: 371 Escape Hatches Across 35,383 Lines

We used the TypeScript compiler API to count every place five production codebases tell the type system to stop checking, then read every one that sits on untrusted input. The count turned out to be the wrong thing to worry about.

18 min read
Share:

Audited against TypeScript 5.9.3 on 2026-08-24.

A TypeScript codebase with strict: true and no any looks secure the way a locked door looks secure: the check is real, and it says nothing about the window. Types are erased before the program runs.1 Every guarantee you have at a trust boundary is a runtime guarantee, and the compiler cannot see it.

So we counted the windows. Across five production codebases we ran the TypeScript compiler API over every source file and counted the three constructs that tell the type checker to stop: type assertions, non-null assertions, and explicit any.

371 of them across 35,383 lines. 87 sit directly on a value that came from outside the program.

Then we read the 70 type assertions among them one at a time, which is the part that changed our minds. Almost none of them were bugs, and the number itself turned out to be close to useless as a risk signal. What matters is something no linter currently reports.

What "Secure" Can Mean in TypeScript, and What It Cannot

TypeScript can stop you from forgetting a check. It cannot stop the check from being wrong, and it cannot perform one.

interface User { id: string } is a claim about a value, enforced everywhere the compiler can trace the value's origin. The moment the value's origin is await request.json(), the compiler has nothing to trace, because json() returns Promise<any>. Whatever type appears at that point appears because a human wrote it there.

That gives a TypeScript security review exactly one useful question: where did the code tell the compiler to stop, and is there a runtime check standing at that spot?

What We Counted

Three constructs, counted from the AST rather than by grep, because as appears in imports and comments and ! appears in every negation:

  • Type assertions (AsExpression and the angle-bracket form), excluding as const, which asserts nothing about shape.
  • Non-null assertions (NonNullExpression, the trailing !).
  • Explicit any (AnyKeyword type nodes).

335 non-declaration .ts and .tsx files, 35,383 non-blank lines, node_modules and build output excluded. All five projects run TypeScript 5.9.3 with strict: true.

import ts from "typescript";
 
const sf = ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true);
 
const visit = (node) => {
  if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) {
    const isConst =
      ts.isTypeReferenceNode(node.type) && node.type.typeName.getText() === "const";
    if (!isConst) record("assertion", node);
  }
  if (ts.isNonNullExpression(node)) record("non-null", node);
  if (node.kind === ts.SyntaxKind.AnyKeyword) record("any", node);
  ts.forEachChild(node, visit);
};
ts.forEachChild(sf, visit);
javascript

Both entry points come straight from the compiler API wiki.2

An assertion counts as "on external input" when the expression being asserted mentions a source the program does not control: JSON.parse, .json(), process.env, localStorage, searchParams, formData, request objects, event.data, fetch, extension storage. That is a text match on the expression subtree, and its limits are in the weaknesses section.

CodebaseLinesas!anyas on external input
Next.js blog (this site)5,43715101
Node/Hono media API11,17477195022
Next.js content site8,5177102
MV3 browser extension5,5591071704
Node/Hono tools API4,6968614041
Total35,383292525070
Every place five production codebases tell the type checker to stop. Lines are non-blank .ts and .tsx, excluding declaration files and build output.

Assertion density runs from 2.76 per thousand lines to 18.3. Same compiler, same team, same style guide, a 6.6x spread. That mirrors what we found when we measured what the strictest compiler flags cost on the same five projects: the shape of the code drives these numbers far more than its size or its discipline does.

Those columns add to 394 rather than 371, because 23 of the 50 any type nodes sit inside an assertion's type, and x as any is one escape hatch rather than two. 371 is the de-duplicated count and it is the one in the title. We mention it because we got it wrong first and the sum is the thing a careful reader checks.

Note also that the line count there is 39,824 and here it is 35,383. Different method, not different code: this pass excludes blank lines and declaration files. The two totals are not comparable to each other.

Grading the 70

An assertion on untrusted input is not one thing. Three grades, and the difference between them matters more than the total:

11 are as any. Ten of them in a single Instagram extractor and one in the Pinterest extractor, all of the form await res.json() as any. This is the only grade that is unambiguously bad, because it does not just skip the check here, it disables checking on everything derived from the value.

18 preserve narrowing. as unknown, as Record<string, unknown>, and shapes whose every property is unknown, such as params as { inputPaths?: unknown }. These look like escape hatches and are the opposite: the compiler still refuses to let you use the value until something narrows it. This site's own newsletter endpoint is in this group, asserting (await request.json()) as SubscribeBody where SubscribeBody declares email?: unknown. The cast buys nothing except a name, and it costs nothing either.

41 claim a concrete shape. as { password: string }, as CropParams, as TikTokPageData. These are the ones that should worry you, because after this line the compiler will let you call .length on something it has never seen.

If the story ended there, the recommendation would be to hunt down all 41. We went and read them instead.

The Finding: The Claims Were Backed, Somewhere Else

Take the worst-looking one. In the tools API, a queue worker does this:

case 'pdf-encrypt':
  await runPdfEncrypt(inputPath, outputPath, params as { password: string }, onProgress)
typescript

params arrived over HTTP. The assertion says it has a string password. Nothing in that file checked.

But the route handler that put the job on the queue did:

export const paramsSchemas: Record<PdfOp, z.ZodTypeAny> = {
  'pdf-encrypt': z.object({ password: z.string().min(1).max(256) }),
  // ...
}
 
const validation = paramsSchemas[op].safeParse(parsedParams)
if (!validation.success) {
  return c.json({ error: 'Invalid params', details: validation.error.flatten() }, 400)
}
typescript

and enqueued validation.data, not the raw body. The assertion is true. It is a restatement of a fact established 150 lines earlier, in a different file, on the other side of Redis.

A vertical flow of the audited pdf-encrypt path: an untrusted POST body reaches the route, which validates it with a zod schema requiring password to be a string of 1 to 256 characters and enqueues the parsed result; the job passes through a Redis queue; a queue worker in a different file then asserts the params as an object with a string password and passes it to qpdf. A separate dashed edge runs directly from the check to the assertion, labelled "true only because of this check, nothing in the type system records it".
The audited pdf-encrypt path. The dashed edge is the dependency: two files and one queue apart, and recorded nowhere the compiler can see.

The extension's event.data as FuseWireEnvelope is the same story with a different guard:

private routeMessage = (event: MessageEvent): void => {
  if (event.origin !== this.origin) return
  if (!this.iframe || event.source !== this.iframe.contentWindow) return
  const envelope = event.data as FuseWireEnvelope | undefined
typescript

The shape is asserted, but the sender is authenticated two lines above, which for postMessage is the check that actually matters.

Read that way, the audit inverts. Of 70 assertions on untrusted input, the ones that turned out to be genuinely unbacked were the 11 as any calls, and those are unsafe for a reason that has nothing to do with the boundary: they poison everything downstream.

The count is not the risk. The distance is. Every one of those 41 assertions is a promise that some other file kept. Nothing in the type system records that dependency, no lint rule can check it, and the value travels through a queue in between. The guarantee holds exactly as long as nobody adds a second way to enqueue a job, and the day somebody does, the compiler will be entirely relaxed about it.

That is the actual output of this audit, and it is not a number. It is a list of places where a runtime check and the assertion that depends on it are far enough apart that neither one mentions the other.

What an Unbacked Assertion Buys You

Worth seeing concretely, because "the type is a lie" is abstract until it costs something. runPdfEncrypt opens with what looks like a solid guard:

if (!params.password || params.password.length < 1 || params.password.length > 256) {
  throw new PdfError('Password must be between 1 and 256 characters')
}
typescript

Suppose the zod schema were not there, and a caller posted {"password": {}}:

typeof p    -> object
p.length    -> undefined
!p          -> false
undefined < 1   -> false
undefined > 256 -> false

The guard passes. params.password then goes into an argv array, and Node stringifies it, so qpdf receives the literal password [object Object] and the user gets a PDF they can never open. No exception, no log line, no crash. Three checks in a row that read as careful and are all no-ops, because they were written against a type rather than against a value.

The one genuinely reassuring thing in that path is unrelated to types: the spawn passes an array, not a shell string, so there is no injection to be had regardless of what the value is.3 TypeScript had nothing to do with that being safe.

Four Functions That Compile Clean and Are Still Bugs

We put these through tsc --noEmit with strict, noUncheckedIndexedAccess and exactOptionalPropertyTypes all on,4 which is stricter than any of the audited projects actually run. Zero errors.

type Settings = Record<string, unknown>;
 
export function deepMerge(target: Settings, source: Settings): Settings {
  for (const key of Object.keys(source)) {
    const value = source[key];
    if (typeof value === "object" && value !== null) {
      target[key] = deepMerge((target[key] as Settings) ?? {}, value as Settings);
    } else {
      target[key] = value;
    }
  }
  return target;
}
 
export function renderBio(bio: string): { __html: string } {
  return { __html: bio };
}
 
export function findUser(db: { query: (sql: string) => unknown }, name: string): unknown {
  return db.query(`SELECT * FROM users WHERE name = '${name}'`);
}
 
interface SubscribeBody { email: string }
export async function handler(request: Request): Promise<string> {
  const body = (await request.json()) as SubscribeBody;
  return body.email.toLowerCase();
}
typescript

The merge is the interesting one, because a nearly identical function is safe and the types cannot tell them apart. JSON.parse('{"__proto__":{"admin":true}}') produces an object with __proto__ as an ordinary own key, so it shows up in Object.keys. Spreading that object is fine: object spread defines own properties, so { ...payload } gives you a harmless object with a literal __proto__ key. Assigning it is not, because target[key] = value goes through the setter.5 We ran both:

after spread,          ({}).polluted -> undefined
after recursive merge, ({}).polluted -> "yes"

Two functions with the same signature, the same parameter types and the same return type. One of them writes to Object.prototype for every object in the process.

renderBio is the shape of most React XSS. dangerouslySetInnerHTML takes { __html: string }, and a string that has been through z.string().min(1) is still exactly a string.6 The validation succeeded and changed nothing about whether the content is safe to inject.

findUser is the case types are structurally unable to help with, because "trusted string" and "attacker-controlled string" are the same type. Branded types are the real answer here, and they have a real cost worth naming: a brand is viral. Once SafeSql exists, every function that touches a query string needs to know about it, and the pressure to write as SafeSql at the one awkward call site is exactly the pressure this whole article is about.

Validation Passing Is Not the Value Being Safe

The most useful thing we found is not in our code at all. Zod is the default answer to everything above, and one of its most-used validators is looser than almost anyone assumes:

z.string().url().safeParse("javascript:alert(1)").success  // true
z.string().url().safeParse("data:text/html,<script>alert(1)</script>").success  // true
typescript

We checked both versions we ship, 3.25.76 and 4.3.6, and the result is the same on each. z.string().url() asks whether the WHATWG URL parser accepts the string, and the URL parser accepts every scheme.7 A schema that reads like it validates a link will happily hand you an XSS payload to put in an href.

Zod 4 has a fix, and it is one argument:

import { z } from "zod";
 
const Link = z.url({ protocol: /^https?$/ });
 
Link.safeParse("javascript:alert(1)").success;  // false
Link.safeParse("data:text/html,x").success;     // false
Link.safeParse("https://ok.example.com").success;  // true
typescript

z.url() also takes hostname, so an allowlist is available in the schema rather than bolted on after.8 On Zod 3 there is no built-in option and you need a .refine that constructs a URL and checks protocol yourself.

This is the general lesson in one line. A validator answers the question it was written to answer, which is nearly always about shape, and almost never about whether the value is safe in the specific place you are about to put it.

Where This Audit Is Weak

  • The five codebases are our own products, and this blog is the site you are reading. Five projects by one team share habits, and shared habits show up as shared results. The 6.6x density spread we found within our own code is a floor on the real variation, not a ceiling. Someone else running these scripts should expect a different table and a different grading split.
  • "External input" is a text match, not a dataflow analysis. It sees await res.json() as Foo and misses a value that was parsed three functions ago and passed down as a parameter. The 87 is a lower bound. A real answer needs the type checker and a taint analysis, which is a much bigger job than an afternoon's script.
  • "Backed by a runtime check" is our reading, not a proof. We traced each of the 41 by hand and found a validator or an origin check upstream in every case we followed. Hand-tracing across a queue boundary is exactly the kind of thing people get wrong, and getting it wrong in the optimistic direction is the failure mode.
  • This is a snapshot, and the property we actually care about is whether a check stays upstream of its assertion as both files change. A count cannot tell you that, and we do not know a good way to enforce it.
  • We did not test anything against a running system. Every claim about our own code comes from reading source and from small local reproductions of the language and library behaviour. The zod results, the prototype pollution results and the tsc runs are real and reproducible. The conclusions about our services are inference from source.

What We Would Actually Do With This

Run the count on your own repo, then ignore the total and look at three things:

  1. Every as any on external input. No defensible reading, and ours are no exception.
  2. Every concrete-shape assertion, traced to the check it depends on. This is the one worth spending an afternoon on, and it is the only step here that produced anything we did not already suspect. Open the file, find the assertion, then go looking for the runtime check that makes it true. If you find it in seconds, fine. If it takes you a minute, write down where it was, because that gap is now a thing two files have to agree about and neither one says so. If you cannot find it at all, you have either found a missing check or found out that the person who wrote the assertion could not find it either. All three outcomes are worth having, and only the last one looks like a bug from the outside.
  3. Every validator you rely on, tested against the value you actually fear. Not against a wrong shape. Against javascript:, against __proto__, against the empty object. Ours took twenty minutes and produced the most actionable result in this article.

This blog runs strict: true and has 15 assertions in 5,437 lines, one of which touches external input, and that one asserts a shape made entirely of unknown. That is a comfortable number. It is also not evidence of anything, which is roughly the point.

FAQ

Does TypeScript make code more secure?

Indirectly and narrowly. It prevents a class of mistake where a value is used without being checked at all, and it makes refactoring safer, which prevents bugs that become vulnerabilities. It performs no runtime validation, all types are erased before execution, and it has no concept of trusted versus untrusted data. Anything you rely on at a trust boundary has to be a runtime check.

Is as unsafe in TypeScript?

as is unsafe when the value's real shape was never established. as on a value you just parsed with a schema is a restatement of something already checked. The dangerous pattern is a concrete shape asserted on a value straight out of JSON.parse, .json() or localStorage, which is 41 of the 70 external assertions we found, and asserting unknown or Record<string, unknown> is not dangerous at all because the compiler still makes you narrow.

Does z.string().url() prevent XSS?

No. On both Zod 3.25.76 and 4.3.6 it accepts javascript:alert(1) and data: URLs, because it only checks that the WHATWG URL parser accepts the string, and that parser accepts every scheme. On Zod 4 use z.url({ protocol: /^https?$/ }). On Zod 3, add a .refine that constructs a URL and checks protocol yourself.

Can TypeScript prevent prototype pollution?

No. We wrote a recursive merge that pollutes Object.prototype and a spread-based merge that does not, gave them identical signatures, and both compiled clean under strict plus noUncheckedIndexedAccess plus exactOptionalPropertyTypes. JSON.parse produces __proto__ as an own enumerable key, so it survives Object.keys, and assignment triggers the prototype setter while spread does not. The type system cannot distinguish the two.

Does strict: true catch security bugs?

It catches missing null checks, which prevents crashes, and it forbids implicit any, which keeps checking alive further into the program. It does not catch injection, pollution, SSRF, unsafe redirects or missing authorisation, because none of those are type errors. Our four-function demo compiles clean under a stricter config than any of the audited projects run.

What should I run on my own codebase?

Count assertions with the compiler API rather than grep, filter to the ones whose asserted expression touches external input, and grade them into any, narrowing-preserving, and concrete-shape. Then trace each concrete-shape one to the runtime check it depends on. The tracing is the part that produces findings; the count is only how you decide where to start.

Is a type assertion the same as a cast?

Not quite, and the difference is why they are risky. A cast in C converts a value. A TypeScript assertion emits nothing at all: it changes what the compiler believes and leaves the runtime untouched. value as User and value compile to identical JavaScript.

Sources

  1. TypeScript handbook: everyday types - that type assertions are removed at compile time and perform no runtime check, and the behaviour of any versus unknown.

  2. TypeScript compiler API wiki - createSourceFile and forEachChild, used for the counting script.

  3. Node.js child_process - that spawn with an argument array does not involve a shell, which is why the argv path in this article is not an injection.

  4. TSConfig reference - which options strict enables, and the exact behaviour of noUncheckedIndexedAccess and exactOptionalPropertyTypes.

  5. MDN: Object.prototype.proto - that assignment through __proto__ invokes a setter on the prototype, and that JSON.parse produces it as an own property instead.

  6. React: dangerouslySetInnerHTML - that the prop takes a plain string and React performs no sanitisation on it.

  7. MDN: URL() constructor - that the WHATWG URL parser accepts any scheme, which is why javascript: passes URL validation.

  8. Zod: string formats - the protocol and hostname options on z.url(), and that URL validation delegates to the platform parser.

Related Posts

12 min read
We turned on noUncheckedIndexedAccess and exactOptionalPropertyTypes across five production codebases and counted every error. The cost per thousand lines varied by 24x, and half the work was not where you index.
By NoWaterProgramming Team
17 min read
How route handlers actually behave in Next.js 16: GET is no longer cached by default, params are async, and most of what you need is a server action instead. With a real deployment that is not on Vercel.
By NoWaterProgramming Team
14 min read
Which strict flags earn their keep and what each one costs, when to reach for @ts-expect-error over @ts-ignore, why catch variables are unknown, and how to type the edges where outside data enters.
By NoWaterProgramming Team