TypeScript Assert
You're working with an API response and TypeScript says everything's fine, but at runtime your app crashes because user.email is undefined. The compiler can't catch these issues because it doesn't run your code. This is where TypeScript's assertion tools come in. You've got two completely different approaches to choose from: type assertions (the as keyword) that just tell the compiler what to think, and assertion functions (the asserts keyword) that actually validate your data at runtime.
Let's cut through the confusion. When developers search for "TypeScript assert," they're usually looking for one of these patterns, and mixing them up leads to bugs.
Type Assertions vs Assertion Functions: What's the Difference?
Before we dive in, you need to understand these are fundamentally different tools.
Type assertions (as keyword) are compile-time only. They disappear when your code runs and don't check anything:
let apiResponse: unknown = { status: "success" };
let status = apiResponse as { status: string }; // No runtime validation!
console.log(status.status); // Works, but risky
Assertion functions (asserts keyword) are the opposite. They validate at runtime and throw errors when data doesn't match expectations:
function assertIsObject(value: unknown): asserts value is object {
if (typeof value !== "object" || value === null) {
throw new Error("Value must be an object");
}
}
let apiResponse: unknown = { status: "success" };
assertIsObject(apiResponse); // Actually checks at runtime
console.log(apiResponse); // TypeScript knows it's an object here
The rest of this guide covers both approaches. We'll start with assertion functions since they're what most developers need for runtime safety, then cover type assertions for completeness.
Assertion Functions: Runtime Type Validation with the asserts Keyword
Assertion functions were introduced in TypeScript 3.7 to solve a real problem: how do you validate data at runtime while also telling TypeScript the type has been narrowed?
Your First Assertion Function
Here's the most common pattern you'll use. This validates that a value exists:
function assertIsDefined<T>(value: T, message?: string): asserts value is NonNullable<T> {
if (value === undefined || value === null) {
throw new Error(message || "Value is null or undefined");
}
}
// Using it with DOM elements
const userButton = document.getElementById("user-btn");
assertIsDefined(userButton, "User button not found");
// TypeScript now knows userButton is HTMLElement, not HTMLElement | null
userButton.addEventListener("click", () => console.log("Clicked"));
The asserts value is NonNullable<T> signature tells TypeScript: "If this function returns normally, the value definitely isn't null or undefined." If the check fails, you never get past the function call because it throws.
String and Type Validation
You can create assertion functions for any type. Here's one for strings:
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error(`Expected string, got ${typeof value}`);
}
}
// Validating user input
function processUsername(input: unknown) {
assertIsString(input);
// TypeScript knows input is a string now
return input.trim().toLowerCase();
}
processUsername("JohnDoe"); // "johndoe"
processUsername(123); // Throws: Expected string, got number
This pattern is useful when handling external data where you can't trust the types. Understanding TypeScript check type operations helps you build more sophisticated validation logic.
Assertion Functions for API Responses
Real-world APIs don't always match your TypeScript interfaces. Here's how to validate the structure:
interface UserResponse {
id: number;
email: string;
displayName: string;
}
function assertIsUserResponse(data: unknown): asserts data is UserResponse {
if (
typeof data !== "object" ||
data === null ||
!("id" in data) ||
!("email" in data) ||
!("displayName" in data)
) {
throw new Error("Invalid user response structure");
}
const record = data as Record<string, unknown>;
if (typeof record.id !== "number") {
throw new Error("User ID must be a number");
}
if (typeof record.email !== "string") {
throw new Error("User email must be a string");
}
if (typeof record.displayName !== "string") {
throw new Error("Display name must be a string");
}
}
async function fetchUser(userId: number): Promise<UserResponse> {
const response = await fetch(`/api/users/${userId}`);
const data: unknown = await response.json();
assertIsUserResponse(data);
// TypeScript knows data is UserResponse now
return data;
}
This approach gives you actual runtime safety. For more validation patterns in production apps, check out Convex's argument validation without repetition approach, which applies similar concepts to database operations.
Simple Condition Assertions
Sometimes you just need to assert that a condition is true, without narrowing to a specific type:
function assert(condition: unknown, message?: string): asserts condition {
if (!condition) {
throw new Error(message || "Assertion failed");
}
}
function calculateDiscount(price: number, discountPercent: number): number {
assert(price > 0, "Price must be positive");
assert(discountPercent >= 0 && discountPercent <= 100, "Discount must be 0-100");
return price * (1 - discountPercent / 100);
}
calculateDiscount(100, 20); // 80
calculateDiscount(-50, 20); // Throws: Price must be positive
This pattern is similar to Node.js's built-in assert module but with TypeScript's type narrowing benefits.
Assertion Functions vs Type Guards: When to Use Each
You might be thinking, "Don't type guards do the same thing?" Not quite. Let's look at the key differences.
Type Guards Return Booleans
Type guards give you a boolean to work with in conditionals:
function isString(value: unknown): value is string {
return typeof value === "string";
}
function processValue(input: unknown) {
if (isString(input)) {
// Handle string case
console.log(input.toUpperCase());
} else {
// Handle other cases
console.log("Not a string");
}
}
Assertion Functions Throw Errors
Assertion functions are more aggressive. They assume the type must be correct or your program should fail:
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error("Must be a string");
}
}
function processValue(input: unknown) {
assertIsString(input);
// No else branch - if we get here, it's definitely a string
console.log(input.toUpperCase());
}
Which Should You Use?
Use type guards when:
- You need to handle multiple valid types differently
- The "wrong" type isn't an error, just a different code path
- You're working with union types where each branch needs different logic
Use assertion functions when:
- The value must be a specific type or the program can't continue
- You're validating external data (APIs, user input, file parsing)
- You want to fail fast when assumptions are violated
- You have error handling further up the call stack
Here's a practical example showing both:
interface Product {
id: string;
price: number;
}
// Type guard - returns boolean
function isProduct(value: unknown): value is Product {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"price" in value &&
typeof (value as Product).id === "string" &&
typeof (value as Product).price === "number"
);
}
// Assertion function - throws on invalid data
function assertIsProduct(value: unknown): asserts value is Product {
if (!isProduct(value)) {
throw new Error("Invalid product data");
}
}
// Use type guard for conditional logic
function renderProductOrPlaceholder(data: unknown) {
if (isProduct(data)) {
return `${data.id}: $${data.price}`;
}
return "No product available";
}
// Use assertion function for required validation
async function saveProduct(data: unknown) {
assertIsProduct(data); // Must be valid or we fail
await database.products.insert(data);
}
The TypeScript instanceof operator offers another way to check types at runtime, particularly useful for class instances.
Understanding Type Assertions with the as Keyword
Now let's shift gears to type assertions. These don't validate anything at runtime, but they're still useful when you know more about a type than TypeScript can infer.
Basic Type Assertion Syntax
The as keyword tells TypeScript to treat a value as a specific type:
let apiResponse: unknown = { name: "TypeScript", version: "5.0" };
let response = apiResponse as { name: string; version: string };
console.log(response.name); // TypeScript allows this
The angle-bracket syntax (<Type>value) does the same thing but conflicts with JSX, so modern TypeScript code uses as.
When Type Assertions Are Appropriate
Type assertions are safe when you genuinely have information TypeScript doesn't:
// Working with DOM - you know the element type
const canvas = document.getElementById("myCanvas") as HTMLCanvasElement;
const ctx = canvas.getContext("2d");
// Handling vendor-specific APIs
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
// Narrowing after runtime checks you've already done
function processConfig(config: unknown) {
if (typeof config === "object" && config !== null && "apiKey" in config) {
const validConfig = config as { apiKey: string };
return validConfig.apiKey;
}
}
For more complex type manipulation patterns, TypeScript satisfies provides additional verification that your values match expected types.
The Risks of Type Assertions
Here's the problem: type assertions can lie. TypeScript trusts you completely:
let userInput: unknown = "not a number";
let age = userInput as number; // TypeScript believes you
console.log(age * 2); // NaN at runtime - no error!
This is why you should prefer assertion functions for external data. Type assertions are best for situations where the type information was lost but you know it's safe, not for validation.
Const Assertions for Literal Types
There's a special type assertion worth knowing: as const. It tells TypeScript to infer the most specific type possible:
// Without as const
const config = {
endpoint: "/api/users",
timeout: 5000
};
// TypeScript infers: { endpoint: string; timeout: number }
// With as const
const strictConfig = {
endpoint: "/api/users",
timeout: 5000
} as const;
// TypeScript infers: { readonly endpoint: "/api/users"; readonly timeout: 5000 }
This is useful for configuration objects and works well with TypeScript const patterns for immutable data.
Combining Assertions and Type Guards for Better Safety
The most robust code uses both approaches. Check types first with guards, then assert when necessary:
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every(item => typeof item === "string");
}
function assertIsStringArray(value: unknown): asserts value is string[] {
if (!isStringArray(value)) {
throw new Error("Expected array of strings");
}
}
function processTags(tags: unknown) {
assertIsStringArray(tags);
// TypeScript knows tags is string[] now
return tags.map(tag => tag.toLowerCase()).join(", ");
}
This layered approach gives you both the runtime safety of assertion functions and the reusability of type guards. For validating arguments in a Convex application, check out their argument validation guide which provides a similar approach.
Validating Complex Data Structures
Real applications deal with nested objects and arrays. Here's how to validate them thoroughly:
interface Address {
street: string;
city: string;
zipCode: string;
}
interface UserProfile {
id: number;
username: string;
addresses: Address[];
}
function isAddress(value: unknown): value is Address {
return (
typeof value === "object" &&
value !== null &&
"street" in value &&
"city" in value &&
"zipCode" in value &&
typeof (value as Address).street === "string" &&
typeof (value as Address).city === "string" &&
typeof (value as Address).zipCode === "string"
);
}
function assertIsUserProfile(value: unknown): asserts value is UserProfile {
if (typeof value !== "object" || value === null) {
throw new Error("User profile must be an object");
}
const obj = value as Record<string, unknown>;
if (typeof obj.id !== "number") {
throw new Error("User ID must be a number");
}
if (typeof obj.username !== "string") {
throw new Error("Username must be a string");
}
if (!Array.isArray(obj.addresses)) {
throw new Error("Addresses must be an array");
}
if (!obj.addresses.every(isAddress)) {
throw new Error("All addresses must be valid Address objects");
}
}
async function loadUserProfile(userId: number): Promise<UserProfile> {
const response = await fetch(`/api/users/${userId}/profile`);
const data: unknown = await response.json();
assertIsUserProfile(data);
return data; // TypeScript knows this is UserProfile
}
The TypeScript types cookbook from Convex offers additional patterns for managing complex types and validators effectively.
Common Mistakes When Using Assertions
Let's look at where developers typically go wrong and how to avoid these issues.
Mistake 1: Using Type Assertions Instead of Validation
Don't use as when you should validate:
// Bad - no runtime safety
async function getUser(id: string) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return data as User; // Hope and pray it's actually a User!
}
// Good - validates at runtime
async function getUser(id: string) {
const response = await fetch(`/api/users/${id}`);
const data: unknown = await response.json();
assertIsUser(data); // Throws if data doesn't match
return data;
}
Mistake 2: Assertion Functions in Arrow Functions
You can't use assertion signatures with arrow function expressions directly:
// This doesn't work
const assertIsString = (value: unknown): asserts value is string => {
if (typeof value !== "string") throw new Error("Not a string");
};
You need to use a type annotation or function declaration:
// Option 1: Function declaration
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") throw new Error("Not a string");
}
// Option 2: Type annotation
type AssertIsString = (value: unknown) => asserts value is string;
const assertIsString: AssertIsString = (value) => {
if (typeof value !== "string") throw new Error("Not a string");
};
Mistake 3: Forgetting Type Narrowing Doesn't Cross Function Boundaries
TypeScript's type narrowing from assertions only works in the same scope:
function processData(data: unknown) {
assertIsString(data);
// This works - same scope
console.log(data.toUpperCase());
// This doesn't narrow data inside the helper
function helper() {
console.log(data.toUpperCase()); // Error if TypeScript is strict
}
}
Pass the narrowed value explicitly or re-assert inside the nested function.
Mistake 4: Over-Asserting When Type Guards Suffice
Don't throw errors when you can handle different types gracefully:
// Over-aggressive
function printValue(value: unknown) {
assertIsString(value); // Throws for non-strings
console.log(value);
}
// Better approach
function printValue(value: unknown) {
if (typeof value === "string") {
console.log(value);
} else {
console.log(String(value));
}
}
When working with Convex, you can leverage their testing utilities to validate your types in a testing environment and catch these issues early.
Assertion Functions with Array Operations
Assertions shine when filtering arrays. Here's how to preserve type information:
interface Product {
id: string;
price: number;
}
function isProduct(value: unknown): value is Product {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"price" in value &&
typeof (value as Product).id === "string" &&
typeof (value as Product).price === "number"
);
}
// Filter and narrow the type
const apiData: unknown[] = [
{ id: "a1", price: 10 },
{ id: "b2" }, // Missing price
{ id: "c3", price: 20 },
"invalid"
];
const validProducts = apiData.filter(isProduct);
// TypeScript knows validProducts is Product[]
validProducts.forEach(product => {
console.log(`${product.id}: $${product.price}`);
});
This pattern is similar to TypeScript type assertion techniques but provides runtime guarantees.
Working with Assertion Signatures and Return Types
Assertion signatures tell TypeScript how a function narrows types. There are two main patterns:
Pattern 1: Asserting a Type Predicate
This form asserts that a value is a specific type:
function assertIsNumber(value: unknown): asserts value is number {
if (typeof value !== "number") {
throw new Error("Value must be a number");
}
}
Pattern 2: Asserting a Condition
This form asserts that a condition is true:
function assert(condition: unknown, message?: string): asserts condition {
if (!condition) {
throw new Error(message || "Assertion failed");
}
}
function processArray(items: string | string[]) {
assert(Array.isArray(items), "Items must be an array");
// TypeScript narrows items to string[] here
items.forEach(item => console.log(item));
}
Both patterns work with TypeScript return type specifications to create more predictable function behaviors.
The end-to-end TypeScript article from Convex Stack explains how to maintain type safety throughout your application stack, which builds on these assertion concepts.
When to Choose Assertions Over Other Patterns
Here's a decision framework for choosing the right approach:
Use assertion functions when:
- Validating external data (APIs, file uploads, user input)
- You want to fail fast on invalid assumptions
- Error handling exists up the call stack to catch thrown errors
- The type must be correct for the program to continue safely
Use type guards when:
- You need to branch logic based on type
- Multiple types are valid but need different handling
- You're working with union types in conditional flows
Use type assertions when:
- You have compile-time type information TypeScript lost
- Working with DOM elements where you know the specific type
- Interfacing with untyped libraries
- After you've already performed runtime validation
Use validation libraries when:
- You need comprehensive schema validation
- You want detailed error messages for each field
- You're building forms or complex data pipelines
- Runtime performance of hand-written assertions becomes a bottleneck
For complex data structures in Convex applications, consider exploring their complex filters approach, which applies similar validation principles. The data types documentation from Convex provides additional insights into working with complex data structures in a type-safe manner.
Final Thoughts on TypeScript Assert
TypeScript gives you two completely different tools called "assertions," and knowing which one to reach for makes the difference between brittle code and robust applications.
Assertion functions with the asserts keyword provide runtime safety. They validate your assumptions and throw errors when data doesn't match expectations. Use them liberally at the boundaries of your system where external data enters.
Type assertions with the as keyword are compile-time hints with zero runtime effect. They're useful when you know more than TypeScript can infer, but they won't catch bad data at runtime. Use them sparingly and only when you're certain about the type.
The best codebases combine both: assertion functions for validation and type guards for conditional logic, with type assertions reserved for the rare cases where the type system needs a nudge. This layered approach catches errors early while keeping your code clean and maintainable.