Mert Özen Further With Every Line
Frontend TypeScript Basics — #12

Type Assertions, !, satisfies and as const in TypeScript: What to Tell the Compiler, and When

Mert Özen Nov 3, 2026 8 min 0 views
Type Assertions, !, satisfies and as const in TypeScript: What to Tell the Compiler, and When

We look at what a type assertion with as does and doesn't check, the risk behind !, how satisfies from TS 4.9 checks while keeping the narrow type, and readonly literals with as const.

Version note: The code in this post targets TypeScript 7.0 and Node 26.

Predict First

Three lines, three different spellings. Which ones compile, and which ones fail?

type User = { name: string; age: number };

const first = { name: "Ada" } as User;
const second: User = { name: "Ada" };
const third = "Ada" as User;

Most people assume the first two lines say the same thing. Make your guess: which of the three get past TypeScript? We'll answer once we've seen what as really does.

Type Assertion: Saying "Trust Me"

Until now, types were either inferred by TypeScript or stated by us with an annotation. With an annotation, TypeScript checks the value against the type you wrote. A type assertion does the opposite: when you write value as SomeType, you tell the compiler "I know this value's type better than you do; from now on, see it this way." It's not a check, it's a claim.

That's where the opening answer comes from. The second line doesn't compile, because the annotation checks the literal against User and sees that age is missing: Property 'age' is missing in type '{ name: string; }' but required in type 'User'. (TS2741). The first line, however, compiles. as doesn't care about the missing property; you said "this is a User," and TypeScript took your word for it.

third doesn't compile, because as has a limit too. If two types have nothing in common, TypeScript objects:

error TS2352: Conversion of type 'string' to type 'User' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.

The rule is roughly this: an assertion is allowed if one of the types is assignable to the other. { name: string } and User have that relationship; string and User don't. The suggestion at the end of the message, converting to unknown first ("Ada" as unknown as User), gets past this limit as well. That line compiles, and that's exactly why it's a red flag in code review.

The main thing to remember is that an assertion does nothing at runtime. Like types, it's erased; it doesn't convert the value and it doesn't check it:

type User = { name: string; age: number };

const first = { name: "Ada" } as User;
console.log(first.age.toFixed(0)); // TypeError: Cannot read properties of undefined (reading 'toFixed')

The compiler went quiet, but the program still crashed. That's why calling this "type casting" is misleading; unlike casts in other languages, no conversion happens here.

When Is as Legitimate?

as isn't forbidden; it's the right tool when your knowledge genuinely exceeds the compiler's. The typical example is data that comes from outside and whose shape you know:

type Settings = { theme: string; fontSize: number };

const raw = '{"theme":"dark","fontSize":14}';
const settings = JSON.parse(raw) as Settings;
console.log(settings.theme); // dark

JSON.parse returns any; with as Settings, your code stays type-safe from that point on. But let's be honest: nothing is being checked here either. You can only know that the JSON really has this shape by validating it at runtime; we'll see that together with unknown when we talk about async code and fetch. You'll also run into as a lot when working with DOM elements in the browser; we'll cover that in the last post of the series.

Here's the yardstick I use: if you can write a sentence next to an as explaining why you're sure, it's probably legitimate. If you can't, you're fooling yourself, not the compiler.

Non-null Assertion: !

! is a special, very short form of assertion: added to the end of an expression, it removes null and undefined from its type. With strict mode on, you run into these two often:

type User = { id: number; name: string };

const users: User[] = [
  { id: 1, name: "Ada" },
  { id: 2, name: "Alan" },
];

const found = users.find((user) => user.id === 3);

// Error: 'found' is possibly 'undefined'.
console.log(found.name);

find may not find anything, so its return type is User | undefined, and TypeScript reports TS18048. If you write found!.name, the error goes away, but in this example there really is no user with id 3. When the code runs you get TypeError: Cannot read properties of undefined (reading 'name'). ! switches off the very check TypeScript put there to protect you from this error.

In most cases there's a better way: the if (found) check we saw in the narrowing post, or a default value like found?.name ?? "unknown". Use ! only where the structure of your code guarantees the value is there but TypeScript can't see it.

satisfies: Checking While Keeping the Narrow Type

For a long time there was a gap between annotations and assertions. satisfies, which arrived in TS 4.9, fills it. Let's look at the problem first:

type Color = string | [number, number, number];
type Palette = Record<"primary" | "accent", Color>;

const palette: Palette = {
  primary: "#0055ff",
  accent: [255, 120, 0],
};

// Error: Property 'toUpperCase' does not exist on type 'Color'.
palette.primary.toUpperCase();

The annotation did its job: it checked the object. But in exchange it also threw information away. We can see that primary is a string; TypeScript now only sees "a Color." Let's write the same object with satisfies:

type Color = string | [number, number, number];
type Palette = Record<"primary" | "accent", Color>;

const palette = {
  primary: "#0055ff",
  accent: [255, 120, 0],
} satisfies Palette;

console.log(palette.primary.toUpperCase()); // #0055FF
console.log(palette.accent.map((channel) => channel / 255).length); // 3

satisfies checks the object against Palette, but it doesn't change the variable's type. The type of palette stays { primary: string; accent: [number, number, number]; }. And the check really works: if you spell accent as acent, tsc says Object literal may only specify known properties, but 'acent' does not exist in type 'Palette'. Did you mean to write 'accent'? (TS2561); if you delete accent entirely, it reports the missing property with TS2741.

as const: Readonly Literals

We took a quick look at as const in the union and literal types post. Although its name starts with as, it isn't a dangerous assertion; it claims nothing and simply asks TypeScript to "see this value in its narrowest, immutable form":

const config = {
  env: "production",
  retries: 3,
  regions: ["eu", "us"],
} as const;

// Error: Cannot assign to 'env' because it is a read-only property.
config.env = "staging";

// Error: Property 'push' does not exist on type 'readonly ["eu", "us"]'.
config.regions.push("asia");

The type of config is now { readonly env: "production"; readonly retries: 3; readonly regions: readonly ["eu", "us"]; }. Three things happened at once: strings and numbers narrowed to literal types, every property became readonly, and the array turned into a readonly tuple. The effect reaches all nested levels.

as const and satisfies Together

The two get along very well. as const freezes and narrows the values, and satisfies checks that the result fits a shape:

type Route = { path: string; title: string };

const routes = {
  home: { path: "/", title: "Home" },
  about: { path: "/about", title: "About" },
} as const satisfies Record<string, Route>;

const homePath = routes.home.path; // type: "/"
console.log(homePath); // /

If you spell title as tittle in a route, you get TS2561; when it's spelled correctly, every path keeps its own literal type. Order matters: as const first, then satisfies. For constant tables, menu definitions and settings lists in an application, this is my favorite pattern.

Which One When?

Annotation (const x: T = …): when you want the variable to really be T, meaning it can later take any value T allows. It checks, but the variable's type becomes T.

satisfies T: when you want to be sure the value fits T without losing the narrow inferred type. It checks and keeps the information.

as const: when the values themselves matter and won't change. It doesn't check; it narrows and freezes, and it pairs well with satisfies.

as T and !: when you have information the compiler can't see and you can defend it in one sentence. Both switch off checking; keep them as a last resort when none of the first three tools does the job.

Common Pitfalls

The first pitfall is using as as an error silencer. When you see a red underline, your first reflex shouldn't be as any or as unknown as X. The error usually points to a real problem; an assertion doesn't fix that problem, it postpones it to runtime.

The second pitfall is believing as const also protects you at runtime. readonly only exists at compile time; in the compiled JavaScript, config is an ordinary, mutable object. If you want immutability at runtime too, you need Object.freeze, and even that only freezes the first level.

The third pitfall is making ! a habit. As the number of ! in a codebase grows, the null safety strict mode gives you quietly disappears. Treat every ! as a small debt and pay it off with a narrowing check whenever you can.

A Small Experiment

★☆☆ Predict whether it compiles: const count = "42" as number;. And const count = "42" as unknown as number;? If the second line compiles, what does count.toFixed(1) do at runtime?

★★☆ In the palette example, write as Palette instead of satisfies Palette. Try deleting accent; do you get an error? Explain the difference from satisfies in your own words.

★★☆ What's the type of sizes in const sizes = ["small", "medium", "large"] as const;? How does the type change when you write the same array without as const? Compare by hovering in your editor.

★★★ Rewrite the users.find example without ! so that it prints User not found to the console when no user is found.

In this post we steered TypeScript's behavior from inside the code, one expression at a time. In the next post we'll make the same kind of adjustments at the project level: we'll open tsconfig.json and go through the strict family, core settings like target and module, the new defaults that came with TypeScript 6 and 7, and the options removed in 7.0.