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 (
AsExpressionand the angle-bracket form), excludingas const, which asserts nothing about shape. - Non-null assertions (
NonNullExpression, the trailing!). - Explicit
any(AnyKeywordtype 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);javascriptBoth 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.
| Codebase | Lines | as | ! | any | as on external input |
|---|---|---|---|---|---|
| Next.js blog (this site) | 5,437 | 15 | 1 | 0 | 1 |
| Node/Hono media API | 11,174 | 77 | 19 | 50 | 22 |
| Next.js content site | 8,517 | 7 | 1 | 0 | 2 |
| MV3 browser extension | 5,559 | 107 | 17 | 0 | 4 |
| Node/Hono tools API | 4,696 | 86 | 14 | 0 | 41 |
| Total | 35,383 | 292 | 52 | 50 | 70 |
.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)typescriptparams 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)
}typescriptand 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.
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 | undefinedtypescriptThe 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')
}typescriptSuppose 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();
}typescriptThe 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 // truetypescriptWe 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; // truetypescriptz.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 Fooand 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
tscruns 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:
- Every
as anyon external input. No defensible reading, and ours are no exception. - 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.
- 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
-
TypeScript handbook: everyday types - that type assertions are removed at compile time and perform no runtime check, and the behaviour of
anyversusunknown. -
TypeScript compiler API wiki -
createSourceFileandforEachChild, used for the counting script. -
Node.js child_process - that
spawnwith an argument array does not involve a shell, which is why the argv path in this article is not an injection. -
TSConfig reference - which options
strictenables, and the exact behaviour ofnoUncheckedIndexedAccessandexactOptionalPropertyTypes. -
MDN: Object.prototype.proto - that assignment through
__proto__invokes a setter on the prototype, and thatJSON.parseproduces it as an own property instead. -
React: dangerouslySetInnerHTML - that the prop takes a plain
stringand React performs no sanitisation on it. -
MDN: URL() constructor - that the WHATWG URL parser accepts any scheme, which is why
javascript:passes URL validation. -
Zod: string formats - the
protocolandhostnameoptions onz.url(), and that URL validation delegates to the platform parser.