Learn

/

Type Assertions

Type Assertions

7 patterns

as const, satisfies, type predicates, and why `as` should be your last resort. You'll hit this when you know more than the compiler but want to prove it safely instead of just overriding.

Avoid
type Color = "red" | "green" | "blue";

const palette = {
  primary: "red",
  secondary: "green",
} as Record<string, Color>;

// Lost the specific keys!
palette.primary;    // Color (not "red")
palette.oops;       // Color (no error for bad key)
type Color = "red" | "green" | "blue";

const palette = {
  primary: "red",
  secondary: "green",
} as Record<string, Color>;

// Lost the specific keys!
palette.primary;    // Color (not "red")
palette.oops;       // Color (no error for bad key)

Prefer
type Color = "red" | "green" | "blue";

const palette = {
  primary: "red",
  secondary: "green",
} satisfies Record<string, Color>;

// Keeps specific keys and values!
palette.primary;    // "red"
palette.oops;       // Error: property doesn't exist
type Color = "red" | "green" | "blue";

const palette = {
  primary: "red",
  secondary: "green",
} satisfies Record<string, Color>;

// Keeps specific keys and values!
palette.primary;    // "red"
palette.oops;       // Error: property doesn't exist
Why avoid

as Record<string, Color> widens the type, losing the specific keys and literal values. TypeScript thinks any string key is valid and every value is the full Color union. You lose the exact type information that makes TypeScript useful.

Why prefer

satisfies validates that a value matches a type without widening it. The value keeps its inferred literal types, so palette.primary is "red" (not Color) and invalid keys are caught. It gives you validation and precision at the same time.

TypeScript 4.9: satisfies operator
Avoid
const config = {
  endpoint: "https://api.example.com",
  retries: 3,
  methods: ["GET", "POST"],
};

// config.endpoint is string (not the literal)
// config.methods is string[] (not tuple)
// config.methods[0] is string (not "GET")
const config = {
  endpoint: "https://api.example.com",
  retries: 3,
  methods: ["GET", "POST"],
};

// config.endpoint is string (not the literal)
// config.methods is string[] (not tuple)
// config.methods[0] is string (not "GET")

Prefer
const config = {
  endpoint: "https://api.example.com",
  retries: 3,
  methods: ["GET", "POST"],
} as const;

// config.endpoint is "https://api.example.com"
// config.methods is readonly ["GET", "POST"]
// config.methods[0] is "GET"
const config = {
  endpoint: "https://api.example.com",
  retries: 3,
  methods: ["GET", "POST"],
} as const;

// config.endpoint is "https://api.example.com"
// config.methods is readonly ["GET", "POST"]
// config.methods[0] is "GET"
Why avoid

Without as const, TypeScript widens literals to their base types: "https://api.example.com" becomes string, and the array becomes string[]. You lose the ability to use these values as discriminants or precise types downstream.

Why prefer

as const makes every property readonly and narrows all values to their literal types. Strings become literal string types, arrays become readonly tuples with literal element types. This is essential for objects used as configuration or lookup tables.

TypeScript 3.4: const assertions
Avoid
interface Fish { swim(): void }
interface Bird { fly(): void }

function isFish(pet: Fish | Bird): boolean {
  return (pet as Fish).swim !== undefined;
}

const pet: Fish | Bird = getPet();
if (isFish(pet)) {
  // Still Fish | Bird here, no narrowing!
  pet.swim(); // Error
}
interface Fish { swim(): void }
interface Bird { fly(): void }

function isFish(pet: Fish | Bird): boolean {
  return (pet as Fish).swim !== undefined;
}

const pet: Fish | Bird = getPet();
if (isFish(pet)) {
  // Still Fish | Bird here, no narrowing!
  pet.swim(); // Error
}

Prefer
interface Fish { swim(): void }
interface Bird { fly(): void }

function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

const pet: Fish | Bird = getPet();
if (isFish(pet)) {
  // Narrowed to Fish
  pet.swim(); // OK
}
interface Fish { swim(): void }
interface Bird { fly(): void }

function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

const pet: Fish | Bird = getPet();
if (isFish(pet)) {
  // Narrowed to Fish
  pet.swim(); // OK
}
Why avoid

A plain boolean return type tells TypeScript nothing about how the argument's type changes. The narrowing logic is locked inside the function, invisible to the caller. The type stays as the full union even after the check passes.

Why prefer

The pet is Fish return type is a type predicate that tells TypeScript the function acts as a type guard. When it returns true, the compiler narrows the argument to Fish in the calling scope. Without the predicate, the boolean return provides no narrowing information.

TypeScript Handbook: Type predicates
Avoid
function loadConfig(): Config | undefined {
  return JSON.parse(fs.readFileSync("config.json", "utf-8"));
}

const config = loadConfig();
// Must check everywhere it's used
if (!config) throw new Error("Missing config");
console.log(config.port);
if (!config) throw new Error("Missing config");
console.log(config.host);
function loadConfig(): Config | undefined {
  return JSON.parse(fs.readFileSync("config.json", "utf-8"));
}

const config = loadConfig();
// Must check everywhere it's used
if (!config) throw new Error("Missing config");
console.log(config.port);
if (!config) throw new Error("Missing config");
console.log(config.host);

Prefer
function assertDefined<T>(
  val: T | undefined,
  msg: string
): asserts val is T {
  if (val === undefined) throw new Error(msg);
}

const config = loadConfig();
assertDefined(config, "Missing config");
// From here on, config is Config (not undefined)
console.log(config.port); // OK
console.log(config.host); // OK
function assertDefined<T>(
  val: T | undefined,
  msg: string
): asserts val is T {
  if (val === undefined) throw new Error(msg);
}

const config = loadConfig();
assertDefined(config, "Missing config");
// From here on, config is Config (not undefined)
console.log(config.port); // OK
console.log(config.host); // OK
Why avoid

Repeating if (!config) throw before every use is noisy and error-prone. It is easy to forget a check, and the intent (fail-fast validation) is buried in boilerplate. Assertion functions centralize the check and communicate the narrowing to the compiler.

Why prefer

An assertion function with asserts val is T tells TypeScript that if the function returns normally (does not throw), the value is narrowed for the rest of the scope. One call replaces repeated null checks throughout the function.

TypeScript 3.7: Assertion functions
Avoid
interface User { name: string; age: number }
interface Product { title: string; price: number }

// Double assertion bypasses ALL type checking
const user = { title: "Widget", price: 10 } as unknown as User;

console.log(user.name); // undefined at runtime!
interface User { name: string; age: number }
interface Product { title: string; price: number }

// Double assertion bypasses ALL type checking
const user = { title: "Widget", price: 10 } as unknown as User;

console.log(user.name); // undefined at runtime!

Prefer
interface User { name: string; age: number }
interface Product { title: string; price: number }

// Use a type guard or validation function
function isUser(val: unknown): val is User {
  return (
    typeof val === "object" &&
    val !== null &&
    "name" in val &&
    "age" in val
  );
}

const data: unknown = fetchData();
if (isUser(data)) {
  console.log(data.name); // safe
}
interface User { name: string; age: number }
interface Product { title: string; price: number }

// Use a type guard or validation function
function isUser(val: unknown): val is User {
  return (
    typeof val === "object" &&
    val !== null &&
    "name" in val &&
    "age" in val
  );
}

const data: unknown = fetchData();
if (isUser(data)) {
  console.log(data.name); // safe
}
Why avoid

as unknown as User is a double assertion that forces any type into any other type with zero runtime checks. It is a complete escape hatch from the type system. The object has title and price, but TypeScript pretends it has name and age. Every property access is a lie.

Why prefer

A type guard validates the shape at runtime, so the narrowed type reflects reality. The compiler trusts the guard, and you can trust the runtime. This is the correct approach when dealing with external data of unknown shape.

TypeScript Handbook: Type assertions
Avoid
interface User {
  address?: {
    city: string;
    zip: string;
  };
}

function getCity(user: User): string {
  // Non-null assertion: "trust me, it exists"
  return user.address!.city;
  // Crashes if address is undefined
}
interface User {
  address?: {
    city: string;
    zip: string;
  };
}

function getCity(user: User): string {
  // Non-null assertion: "trust me, it exists"
  return user.address!.city;
  // Crashes if address is undefined
}

Prefer
interface User {
  address?: {
    city: string;
    zip: string;
  };
}

function getCity(user: User): string | undefined {
  // Optional chaining: safe access
  return user.address?.city;
  // Returns undefined if address is missing
}
interface User {
  address?: {
    city: string;
    zip: string;
  };
}

function getCity(user: User): string | undefined {
  // Optional chaining: safe access
  return user.address?.city;
  // Returns undefined if address is missing
}
Why avoid

The non-null assertion operator (!) tells TypeScript to pretend a value is not null or undefined. It is erased during compilation and provides zero runtime protection. If address is actually undefined, the code throws a TypeError.

Why prefer

Optional chaining (?.) safely returns undefined if any part of the chain is nullish. The return type honestly reflects the possibility of undefined. This is a runtime-safe operation, unlike the non-null assertion which is erased at compile time.

TypeScript 3.7: Optional chaining
Avoid
type UserId = number;
type PostId = number;

function getUser(id: UserId) { }
function getPost(id: PostId) { }

const oderId: PostId = 42;
getUser(oderId); // No error, PostId is just number
type UserId = number;
type PostId = number;

function getUser(id: UserId) { }
function getPost(id: PostId) { }

const oderId: PostId = 42;
getUser(oderId); // No error, PostId is just number

Prefer
type Brand<T, B extends string> =
  T & { readonly __brand: B };

type UserId = Brand<number, "UserId">;
type PostId = Brand<number, "PostId">;

function getUser(id: UserId) { }
function getPost(id: PostId) { }

const userId = 1 as UserId;
const postId = 2 as PostId;
getUser(userId);  // OK
// getUser(postId); // Error
type Brand<T, B extends string> =
  T & { readonly __brand: B };

type UserId = Brand<number, "UserId">;
type PostId = Brand<number, "PostId">;

function getUser(id: UserId) { }
function getPost(id: PostId) { }

const userId = 1 as UserId;
const postId = 2 as PostId;
getUser(userId);  // OK
// getUser(postId); // Error
Why avoid

Plain type aliases for primitives are structurally identical. UserId and PostId are both just number, so TypeScript treats them as interchangeable. Accidentally passing a post ID where a user ID is expected compiles without error and causes bugs at runtime.

Why prefer

A branded type adds a phantom __brand property that exists only at the type level. This makes UserId and PostId structurally incompatible even though both are numbers at runtime. The as cast is typically wrapped in a factory or validation function so callers never see it.

TypeScript Playground: Nominal Typing