Every Type Assertion Is an Unchecked Claim

Search your codebase for as and count the hits. Then count the hits for !. and !). That number is how many places your type checker stopped checking and started taking your word for it.
A type assertion is not a conversion. It emits no code. value as User compiles to value, period, and at runtime nothing anywhere confirms that the thing is a User. The compiler did not verify your claim. It deleted the question.
That is fine when the claim is true. The problem is that nothing tells you when it stops being true.
What an assertion actually does
TypeScript allows x as T whenever the two types overlap enough that the cast is not obviously absurd. If they do not overlap, you get the “Conversion of type … may be a mistake” error, and the compiler suggests the escape hatch itself: go through unknown first.
const port = "8080" as number; // error: types do not sufficiently overlap
const port2 = "8080" as unknown as number; // compiles. port2 is a string.
The double cast is the honest version. It reads exactly like what it is: “I know this is wrong, do it anyway.” The single cast is the dangerous one, because it looks reasonable and still proves nothing.
interface Config {
apiUrl: string;
retries: number;
}
const config = {} as Config; // compiles
config.retries.toFixed(0); // TypeError at runtime
An empty object literal “overlaps” with any object type, so the compiler accepts it. You now have a variable whose type says every field is present and whose value has none of them.
The five assertions that show up in every codebase
1. Parsed JSON
const data = JSON.parse(raw) as Post[];
const user = (await res.json()) as User;
JSON.parse returns any, and so does Response.json(). The cast here is not even doing work, since any assigns to anything. It is decoration that makes the line look checked. If the API adds a field, renames a field, or returns an error envelope with a 200, this code types happily and fails three function calls later.
2. DOM lookups
const input = document.querySelector("#email") as HTMLInputElement;
input.value = ""; // TypeError if the element is missing
querySelector returns Element | null. The cast narrows to a subtype and quietly drops the null in the same move. Rename the ID in the template and this becomes a runtime crash with a perfectly green type check.
3. Non-null assertions
const slug = params.get("slug")!;
const post = posts.find((p) => p.slug === slug)!;
The ! operator is an assertion with better marketing. Every one of them is a place where the type system told you “this might be missing” and you answered “no it won’t.” Sometimes you are right. The type checker has no way to notice when a refactor makes you wrong.
4. Caught errors
try {
await publish();
} catch (e) {
log((e as Error).message);
}
Under strict, catch variables are unknown, and that is correct. JavaScript lets you throw anything: a string, a rejected value from a library, undefined. Casting to Error turns your error handler into a second place that can throw.
5. Object keys
for (const key of Object.keys(settings) as (keyof Settings)[]) {
apply(key, settings[key]);
}
Object.keys returns string[] on purpose. TypeScript object types are open: a value typed as Settings can legally carry extra properties it picked up from a spread or a wider object. The cast claims a closed set of keys the type system never promised.
Replace the claim with a check
Most assertions have a direct replacement that makes the compiler do the work again.
For object literals, use satisfies. It checks the value against the type without widening or overriding what was inferred, and it fails loudly when a field is missing.
const config = {
apiUrl: "https://api.example.com",
retries: 3,
} satisfies Config;
For anything nullable, narrow it. A conditional is one line longer than a ! and turns a mystery crash into a clear error at the right place.
const input = document.querySelector<HTMLInputElement>("#email");
if (!input) throw new Error("Missing #email input in signup form");
input.value = "";
The generic form of querySelector is still a claim about the element type, but it is a smaller one, and the null is still yours to handle.
For caught errors, check the type.
} catch (e) {
log(e instanceof Error ? e.message : String(e));
}
For data crossing a boundary, parse it. Type the raw value as unknown, then validate. A schema library, a hand-written guard, it does not matter much. What matters is that the type comes out of a check, not out of a cast.
const raw: unknown = await res.json();
const user = UserSchema.parse(raw); // throws with a useful message if the shape is wrong
Annotating the result as unknown is the important habit. Once the value is unknown, the compiler refuses to let you touch it until you prove what it is.
Type guards are claims too
A user-defined type guard looks like a check, but the compiler does not verify that its body matches its signature.
function isPost(value: unknown): value is Post {
return typeof value === "object" && value !== null && "slug" in value;
}
That function promises a full Post and checks one key. Every caller now trusts a title, a publishedAt, and a categories array that nobody looked at. Assertion functions with asserts value is T have the same property.
Guards are still better than casts, because the claim lives in one named, testable function instead of being scattered across forty call sites. Treat each one as code that needs a unit test, because the compiler will not catch a guard that lies. TypeScript 5.5 and later can infer simple predicates on its own, such as a filter callback that removes undefined, and an inferred predicate is always one you did not have to get right by hand.
Make the lint rules say it out loud
You will not remove every assertion, and you should not try. as const is not a lie; it narrows to exactly what you wrote. Test fixtures and genuinely untyped third-party code will need the occasional cast. The goal is that every remaining assertion is deliberate and visible in review.
typescript-eslint already has the rules:
{
"rules": {
"@typescript-eslint/consistent-type-assertions": [
"error",
{ "assertionStyle": "as", "objectLiteralTypeAssertions": "never" }
],
"@typescript-eslint/no-non-null-assertion": "error",
"@typescript-eslint/no-unnecessary-type-assertion": "error",
"@typescript-eslint/no-unsafe-assignment": "error",
"@typescript-eslint/no-unsafe-member-access": "error"
}
}
objectLiteralTypeAssertions: "never"bans the empty-object cast pattern outright and pushes people tosatisfiesor an annotation.no-non-null-assertionmakes every!an explicit, commented exception.no-unnecessary-type-assertiondeletes casts that no longer do anything after a refactor.- The two
no-unsafe-*rules catch theanythatJSON.parseandres.json()leak into your code before a cast can hide it. They need type-aware linting turned on, which costs some lint time and is worth it.
When a cast really is necessary, write the reason next to it with an eslint-disable comment. A reviewer can argue with a sentence. Nobody argues with a bare as.
For a quick baseline before you turn the rules on:
grep -rnE " as [A-Za-z(\[]| as unknown as |\!\.|\!\)" src --include="*.ts" --include="*.tsx" | grep -v " as const" | wc -l
It is crude and will over-count, but it gives you a number to watch go down.
The takeaway
Your type checker is only as honest as the sentences you feed it. Every as and every ! is a sentence it accepted without evidence, and it will keep accepting it long after the code around it changes.
Annotate boundaries as unknown, narrow instead of asserting, reach for satisfies when you want a check rather than an override, and make the lint config refuse the rest unless someone writes down why. The casts that survive should be few enough that you can name them.

