Type Assertions
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.
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)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 existtype 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 existas 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.
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.
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")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"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.
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.
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
}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
}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.
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.
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);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); // OKfunction 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); // OKRepeating 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.
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.
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!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
}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.
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.
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
}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
}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.
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.
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 numbertype UserId = number;
type PostId = number;
function getUser(id: UserId) { }
function getPost(id: PostId) { }
const oderId: PostId = 42;
getUser(oderId); // No error, PostId is just numbertype 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); // Errortype 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); // ErrorPlain 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.
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.