Type Assertions vs. Real Type Safety in TypeScript

8 min read
TypescriptCoding
Type Assertions vs. Real Type Safety in TypeScript

What is a type assertion?

It’s a mechanism to essentially tell the compiler to trust you, rather than second guessing you. To simply put it:

Trust me - I know this value is of this shape.

You may find that you’re using type assertions when migrating Javascript code to TypeScript, where you may not be sure about the shape of the data you’re working with. Consider the following pattern:

var person = {};

person.name = 'John'; // Error: Property 'name' does not exist on type 'unknown'
person.age = 30; // Error: Property 'age' does not exist on type 'unknown'

This code errors, but by casting the value to a Person type, we can tell the compiler to trust us:

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

var person = {} as Person;

person.name = 'John'; // No error
person.age = 30; // No error

as Person vs. <Person>

There are two ways to cast a value to a type in TypeScript:

const value = getSomething();
const typed = value as Person;
const value = getSomething();
const typed = <Person>value;

Type Assertions vs. Casting

The reason why it’s not called “type casting” is that casting generally implies some sort of runtime support. However, type assertions are purely a compile time construct and a way for you to provide hints to the compiler on how you want your code to be analysed.

Why overusing assertions is dangerous

You’re essentially not using TypeScript’s type safety at all.

For example, if you cast an object from an API call as response as XInterface, the compiler will not check if the object actually conforms to the XInterface type. This can hide runtime mismatches. If the actual object differs from XInterface, the compiler stays silent until something breaks. This is why it’s important to use type guards.

An example of bad type assertions:

const response = getResponseFromStream(); // unknown shape
const typed = response as XInterface;

console.log(typed.state); // runtime error if state doesn't exist

This can easily lead to:

  • Accessing missing or mistyped properties.
  • undefined values slipping through tests.
  • A false sense of confidence: “it compiles, so it must be safe.”

The as keyword silences the compiler, removing the safety net that TypeScript is supposed to give you.

When is an assertion acceptable?

Type assertions aren’t evil. They’re just risky. There are legitimate cases for them, but they should be used sparingly.

Working with external data

For example, APIs, browser globals, or DOM methods that return unknown or any. You know the structure better than the compiler does, so it’s acceptable to use an assertion to tell the compiler to trust you.

const element = document.querySelector('#app') as HTMLDivElement;

Third party libraries with incomplete types

Sometimes you’re working with a library that has poor or outdated type definitions. Rather than fighting the compiler, a targeted assertion can unblock you whilst keeping the rest of your code type safe.

const config = legacyLib.getConfig() as MyConfigType;

When you genuinely know more than the compiler

After narrowing with control flow, sometimes the compiler still doesn’t infer what you know to be true.

type Response = { success: true; data: string } | { success: false; error: string };

const process = (response: Response) => {
  if (response.success) {
    console.log(response.data);
  } else {
    // Compiler knows this is the error branch
    console.log(response.error);
  }
}

However, in more complex scenarios, you might need an assertion to help the compiler along.


Better alternatives to type assertions

Rather than relying on as everywhere, here are some approaches that maintain true type safety.

Type guards

A type guard is a runtime check that also informs the compiler about the type. This combines runtime validation with compile time safety.

const isUser = (obj: unknown): obj is User => {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'name' in obj &&
    typeof obj.name === 'string' &&
    'age' in obj &&
    typeof obj.age === 'number'
  );
}

const data = getExternalData();
if (isUser(data)) {
  console.log(data.name); // Safe!
}

Validation libraries

For complex data structures, writing manual type guards becomes tedious. Libraries like Zod, io-ts, or Yup let you define schemas that validate at runtime and infer types at compile time.

import { z } from 'zod';

const UserSchema = z.object({
  name: z.string(),
  age: z.number(),
});

type User = z.infer<typeof UserSchema>;

const data = getExternalData();
const user = UserSchema.parse(data); // Throws if invalid
console.log(user.name); // Fully typed and validated

This approach gives you both runtime safety and compile time safety without any assertions.

Discriminated unions

When you have multiple possible shapes, use discriminated unions rather than asserting which one it is.

type Success = { status: 'success'; data: string };
type Error = { status: 'error'; message: string };
type Result = Success | Error;

const handle = (result: Result) => {
  if (result.status === 'success') {
    console.log(result.data);
  } else {
    console.log(result.message);
  }
}

The compiler knows exactly which properties are available in each branch. So, no assertions needed.

Proper type definitions from the start

Instead of building up objects incrementally and casting:

// Bad
const person = {} as Person;
person.name = 'John';
person.age = 30;

Define the shape properly from the beginning:

// Good
const person: Person = {
  name: 'John',
  age: 30,
};

If you need to build an object step by step, use Partial types or factory functions:

function createPerson(name: string, age: number): Person {
  return { name, age };
}

The mental shift

Type assertions are a compiler override. They say “ignore your checks, I know better.” Sometimes that’s necessary, but it should be rare. The goal of TypeScript is to catch mistakes before runtime. When you bypass that with assertions, you’re essentially writing JavaScript with extra steps.

Instead, think of types as a helpful friend, not an obstacle. If the compiler complains, that’s often a signal that something genuinely might go wrong. Rather than silencing it with as, ask yourself:

  • Can I narrow this type with a guard?
  • Should I validate this data at runtime?
  • Is my type definition incomplete or incorrect?
  • Am I trying to force something that shouldn’t be forced?

Summary

Type assertions have their place, but they’re a last resort, not a first instinct. They trade compile time safety for convenience, which is occasionally necessary but often avoidable. By leaning on type guards, validation libraries, and proper type definitions, you can write TypeScript that’s both safe and expressive, without constantly telling the compiler to trust you.

Use as when you must. Use types when you can. Your future self will thank you when the compiler catches a bug before it reaches production.