Stop Using TypeScript's Exclamation Mark
The non-null assertion operator (!) tells TypeScript to trust you when you say a value isn't null or undefined. But this trust comes at a cost: runtime crashes that TypeScript could have prevented. Learn why this operator undermines type safety and what to use instead.
The non-null assertion operator (!) is one of TypeScript's most dangerous features. It looks innocent, just a single character added to your code. But that exclamation mark does something profound: it tells the TypeScript compiler to stop protecting you. It's a promise that you know better than the type system, and when that promise breaks, your application crashes at runtime with the exact errors TypeScript was designed to prevent.
Contents
- What the Exclamation Mark Does
- Better Alternatives
- Building Defensive Code
- When to Use What
- Enforcing Best Practices with ESLint
What the Exclamation Mark Does
The non-null assertion operator tells TypeScript to treat a potentially nullable value as definitely non-null. When you append ! to a value, you're instructing the compiler to trust that the value exists, regardless of what the type system thinks.
Invariants are conditions that must always hold true at specific points in your program. When you use the non-null assertion operator, you're claiming an invariant without actually verifying it. For example, user.email! asserts the invariant "this email definitely exists" but does nothing to enforce or verify that claim.
interface User {
email?: string;
}
function printEmail(user: User) {
console.log(user.email!.toLowerCase());
}
// Dangerous!
printEmail({});TypeScript sees user.email as string | undefined, but the ! operator forces it to be treated as just string. The compiler stops checking whether the value might be undefined and lets you use it as if it's guaranteed to exist. This compiles without errors, but if user.email is actually undefined at runtime, your code will crash with a TypeError and the message "Cannot read properties of undefined".
Better Alternatives
For every situation where you're tempted to use the non-null assertion operator, there's a safer alternative that preserves type safety.
Optional Chaining
You might be tempted to replace the non-null assertion with optional chaining:
interface User {
email?: string;
}
function printEmail(user: User) {
console.log(user.email?.toLowerCase());
}
printEmail({});While this doesn't crash your code, it produces unexpected output. The function logs undefined to the console, which isn't meaningful for users or developers debugging the application. The code silently continues with invalid data rather than alerting you to the problem.
This violates the principle of fail-fast systems, where errors should be detected and reported as early as possible. When you proceed with unexpected values like undefined, the actual problem becomes harder to diagnose because the failure happens far from its source. It's better to fail immediately with a clear error than to let invalid data propagate through your system.
Nullish Coalescing
The nullish coalescing operator (??) provides a default value when the original value is null or undefined. This works well for return values or variable initialization where you want to provide a sensible fallback:
interface User {
email?: string;
}
function printEmail(user: User) {
console.log(user.email ?? ''.toLowerCase());
}
printEmail({});This approach prevents crashes and avoids logging undefined, but it might still produce unhelpful output. In this example, when the email is missing, the function logs an empty string, which may not clearly communicate that the user lacks an email address.
The nullish coalescing operator is most effective when you have a meaningful default value that makes sense in your application's context.
Example:
interface User {
email?: string;
}
function getEmail(user: User) {
return user.email ?? 'Unknown';
}It becomes even more useful for accessing nested properties that might not exist:
function getUserCity(user: User) {
return user.address?.city ?? 'Unknown';
}Conditional Operator
When you need different behavior based on whether a value exists, the ternary operator makes your intent explicit:
interface User {
email?: string;
}
function printEmail(user: User) {
console.log(user.email ? user.email.toLowerCase() : 'User has no email address.');
}
printEmail({});This clearly handles both cases and provides meaningful output for each scenario.
Type Guards
When you need to narrow types based on runtime checks, use proper type guards. Unlike the non-null assertion operator, type guards cannot crash your code. They perform actual runtime checks and let you handle both the success and failure cases:
interface User {
email?: string;
}
// Type Guard
function hasEmail(email: string | undefined): email is string {
return email !== undefined;
}
function printEmail({ email }: User) {
if (hasEmail(email)) {
console.log(email.toLowerCase());
}
console.log(`User has no email address.`);
}
printEmail({});The type guard makes the check explicit and allows TypeScript to narrow the type safely. Type guards can also be shared across your applicaiton.
Assertion Functions
When you need to validate values and throw errors if they're invalid, use assertion functions. Unlike type guards, assertion functions do crash your code when the value is invalid, but they do so intentionally with clear error messages. This makes them particularly valuable in test cases where you want failing assertions to stop execution immediately.
Example:
interface User {
email?: string;
}
export class UserValidator {
static validateEmail(user: User): User | undefined {
if (!user.email) {
return undefined;
}
return user;
}
}import { describe, expect, it } from 'vitest';
import { UserValidator } from './UserValidator.js';
// Assertion Function
function assertDefined<T>(value: T | undefined): asserts value is T {
expect(value).toBeDefined();
}
describe('UserValidator', () => {
it('validates user emails', () => {
const user = { email: 'mail@domain.com' };
const validatedUser = UserValidator.validateEmail(user);
assertDefined(validatedUser);
expect(validatedUser.email).toBe(user.email);
});
});The assertDefined assertion function performs two critical tasks. First, it verifies at runtime that the value is actually defined, failing the test with a clear message if it's not. Second, it narrows the TypeScript type from User | undefined to User, enabling type-safe access to properties.
Without this assertion function, attempting to access validatedUser.email in the expectation would produce the error message: "'validatedUser' is possibly 'undefined'."
Assertion Functions from Testing Frameworks
In test cases, you can leverage assertions from your testing framework to both validate values and narrow types. This combines type safety with the familiar testing syntax you already use. Vitest re-exports the assert method from chai, which is particularly useful for verifying invariants in your tests:
import { assert, describe, expect, it } from 'vitest';
describe('UserValidator', () => {
it('validates user emails', () => {
const user = { email: 'mail@domain.com' };
const validatedUser = UserValidator.validateEmail(user);
assert.exists(validatedUser);
expect(validatedUser.email).toBe(user.email);
});
});Assertion Functions from Node.js
For non-test code and test code, Node.js provides a built-in assert module that works similarly to Vitest's assert. The standard assert function narrows types automatically:
import assert from 'node:assert';
import { describe, expect, it } from 'vitest';
import { UserValidator } from './UserValidator.mjs';
describe('UserValidator', () => {
it('validates user emails', () => {
const user = { email: 'mail@domain.com' };
const validatedUser = UserValidator.validateEmail(user);
assert.ok(validatedUser);
expect(validatedUser.email).toBe(user.email);
});
});Node.js's assert module is particularly useful when validating the existence of process.env variables at the beginning of a script. This ensures your application fails immediately with a clear error if required configuration is missing, rather than crashing mysteriously later:
import assert from 'node:assert';
// Validate required environment variables on startup
assert.ok(process.env.DATABASE_URL, 'DATABASE_URL environment variable is required');
assert.ok(process.env.API_KEY, 'API_KEY environment variable is required');
// TypeScript now knows these are strings, not "string | undefined"
export const config = {
databaseUrl: process.env.DATABASE_URL,
apiKey: process.env.API_KEY,
};This is far superior to using the non-null assertion operator.
Building Defensive Code
The goal of using TypeScript is to catch errors at compile time rather than runtime. Instead of reaching for the exclamation mark, take a moment to consider why TypeScript is warning you. Rather than silencing that warning, embrace it. Handle the null case explicitly, restructure your types to make invalid states impossible, or use proper type guards and assertions that provide runtime safety.
When to Use What
Choosing the right approach depends on your intent:
| Approach | When to Use | Example |
|---|---|---|
Optional Chaining (?.) | Accessing nested properties where missing values are acceptable. You can work with undefined in subsequent operations. | user.profile?.address?.city |
Nullish Coalescing (??) | When you have a meaningful default value to use as a fallback. Most effective for return values or variable initialization. | user.name ?? 'Anonymous' |
Conditional Operator (?) | When you need different logic or output for the present vs. absent case. Makes your intent explicit with separate handling for each scenario. | user.email ? sendEmail(user.email) : showError() |
Type Guard (is) | When you need reusable validation logic across your application. Use for graceful error handling with control over both success and failure cases. | if (isValidEmail(email)) { ... } |
Assertion Function (asserts) | When a missing value represents a programming error that should crash immediately. Particularly valuable in tests and for verifying invariants that must hold true. | assertDefined(config.apiKey, 'Required') |
Enforcing Best Practices with ESLint
The most effective way to prevent non-null assertions from sneaking into your codebase is to forbid them entirely through ESLint rules. TypeScript ESLint provides the @typescript-eslint/no-unnecessary-type-assertion rule that catches these dangerous patterns during development:
export default tseslint.config({
rules: {
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
},
});