10.6 The asserts value Pattern for Runtime Validation
Right, let’s talk about turning your runtime checks into compile-time guarantees. You’ve probably written a function like this a hundred times:
function getStringLength(str: unknown): number {
if (typeof str === 'string') {
// TypeScript is smart enough to know `str` is a string in here.
return str.length;
}
throw new Error('Expected a string, you maniac.');
}
It works. But it’s a bit… clunky. You have to handle the error case, and the calling code is none the wiser until it blows up at runtime. Wouldn’t it be better if we could tell the TypeScript compiler, “Hey, I’m not just checking this—I’m promising it, and if I’m wrong, I’ll crash this whole operation myself so the rest of your code can proceed with confidence”?
Enter asserts value is T. It’s not a function that returns a value; it’s a function that side-effects on the type system. It’s the technical equivalent of slamming your fist on the table and shouting, “TRUST ME ON THIS.”
How asserts Actually Works
The syntax looks a bit alien at first, but it makes perfect sense when you break it down. You’re not specifying a return type (because it returns void or never); you’re specifying an assertion on one of the parameters.
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new Error(`Expected a string, got ${typeof value}`);
}
// No return statement needed. The magic is in the throws-or-not.
}
let someData: unknown = "Hello, world!";
assertIsString(someData);
// The compiler now knows `someData` is a string from this point onward.
console.log(someData.toUpperCase()); // No error, totally safe.
The beauty here is in the control flow analysis. TypeScript understands that if the function doesn’t throw an exception, the condition must be true. Therefore, after the function call, it can confidently narrow the type of value (or in this case, someData) to string. If the function throws, well, the code after the call never runs, so the narrowed type is irrelevant. It’s a genius design.
The Power of Custom Assertion Functions
This pattern shines when you move beyond simple typeof checks. Imagine validating an API response. This is where you go from “helpful” to “indispensable.”
interface UserProfile {
id: string;
username: string;
email: string;
}
// A function that does a more complex validation
function assertIsUserProfile(data: any): asserts data is UserProfile {
if (
!data ||
typeof data.id !== 'string' ||
typeof data.username !== 'string' ||
typeof data.email !== 'string'
) {
throw new Error(`Malformed UserProfile: ${JSON.stringify(data)}`);
}
// You could add more checks here, like email format, etc.
}
// Fetch some dubious data from an external source
const apiResponse: any = await fetch('/api/user/123').then(res => res.json());
// This line is the gatekeeper. It either throws a descriptive error
// or tells TypeScript that apiResponse is a UserProfile.
assertIsUserProfile(apiResponse);
// Now you can use it with full type safety and confidence.
console.log(`Hello, ${apiResponse.username}! Your ID is ${apiResponse.id}.`);
This is lightyears better than just casting with as UserProfile, which is like telling the compiler “shut up, I know what I’m doing” even when you absolutely do not. This proves you know what you’re doing.
The Subtle Pitfall: asserts and Boolean Returns
Here’s a common point of confusion. Don’t mix up type predicates (which return a boolean) with assertion functions (which don’t return at all).
// This is a type guard (predicate). It returns a boolean.
function isUserProfile(data: any): data is UserProfile {
return data && typeof data.id === 'string' /* ... etc */;
}
// This is an assertion function. It returns void and throws on failure.
function assertIsUserProfile(data: any): asserts data is UserProfile {
if (!isUserProfile(data)) { // Hey, we can reuse the predicate!
throw new Error('Validation failed');
}
}
The key difference is how you use them. The predicate requires an if statement; the assertion function is the statement. Use asserts when failure should be exceptional (like validating crucial input). Use the predicate when you’re checking for an optional condition and want to handle both cases gracefully.
Best Practice: Reuse and Composition
The best code is boring code. Don’t write the same validation logic in ten different assertion functions. Write a small library of reusable predicates (isString, isNumberArray, etc.) and then build your more complex asserts functions on top of them, just like in the example above. It’s easier to test, maintain, and reason about. You’re not just making types safer; you’re making your actual validation logic robust and DRY.
This pattern is the ultimate bridge between the messy reality of runtime data and the pristine, safe world of your compile-time types. It’s you taking responsibility for that boundary, and that’s what separates a novice from a pro.