TypeScript anti-patterns: a hands-on guide for engineers
We've all inherited a codebase where type safety feels like a suggestion rather than a guardrail. TypeScript gives us powerful tools to catch errors at compile time, but it's easy to reach for shortcuts that undermine the entire premise. In this guide, we'll walk through the habits that weaken type safety and show you how to recognize and eliminate them.
Why anti-patterns matter in TypeScript
TypeScript anti-patterns aren't just style issues—they're decisions that push errors from compile time into runtime. When we use as casts carelessly, scatter non-null assertions throughout our code, or duplicate types instead of deriving them, we're essentially opting out of the type system when we need it most. The teams we've worked with in e-commerce, fintech, and SaaS all hit the same wall: code that looks type-safe but behaves unpredictably in production.
Let's examine the most damaging patterns and what to do instead.
The as cast problem: silencing errors instead of solving them
Type assertions using as bypass the compiler entirely. When you cast with as, you're telling TypeScript: "Trust me, I know the type." Often you're wrong, and the compiler gives up trying to help.
Here's a real scenario from a ride-hailing platform we worked on:
interface Driver {
id: string;
name: string;
rating: number;
}
const response = await fetch('/api/driver');
const data = await response.json();
// Anti-pattern: blind cast
const driver = data as Driver;
console.log(driver.rating + 10); // Crashes if rating is undefined
The API might return a shape we don't expect. The as cast silences TypeScript, but it doesn't change reality. Six months later, an API change sends a different field name, and your code breaks in production.
Instead, validate and narrow types explicitly:
interface Driver {
id: string;
name: string;
rating: number;
}
function isDriver(obj: unknown): obj is Driver {
return (
typeof obj === 'object' &&
obj !== null &&
typeof (obj as any).id === 'string' &&
typeof (obj as any).name === 'string' &&
typeof (obj as any).rating === 'number'
);
}
const response = await fetch('/api/driver');
const data = await response.json();
if (isDriver(data)) {
console.log(data.rating + 10); // Safe
} else {
throw new Error('Invalid driver data');
}
This approach moves the trust from the compiler to a runtime validation function. You still use as inside the guard, but only for the narrow scope where you're actually checking. If the API changes, you fail loudly instead of silently.
Non-null assertions: a false sense of security
The non-null assertion operator (!) tells TypeScript: "This value is not null or undefined, I promise." Like as, it's a way to override the type system. And like as, it often reflects our impatience rather than our certainty.
We see this constantly in React and DOM code:
function setupForm() {
// Anti-pattern: non-null assertion
const button = document.getElementById('submit-btn')!;
button.addEventListener('click', handleSubmit);
}
What if the button doesn't exist? What if the HTML changes? The ! silences the warning, but it doesn't prevent the runtime error.
The better approach handles the null case:
function setupForm() {
const button = document.getElementById('submit-btn');
if (!button) {
console.error('Submit button not found');
return;
}
button.addEventListener('click', handleSubmit);
}
Or, if you're confident the element exists, use a type guard and make that intent clear:
function getRequiredElement(id: string): HTMLElement {
const element = document.getElementById(id);
if (!element) {
throw new Error(`Required element with id "${id}" not found`);
}
return element;
}
function setupForm() {
const button = getRequiredElement('submit-btn');
button.addEventListener('click', handleSubmit);
}
This way, the failure is explicit and intentional. Future maintainers understand why null-checking is skipped.
Optional properties instead of unions: spreading the uncertainty
When behavior changes based on which properties exist, optional properties create ambiguity. Discriminated unions are clearer and force better thinking about your domain.
Consider an order status system from an e-commerce platform:
// Anti-pattern: optional properties everywhere
interface Order {
id: string;
status: 'pending' | 'shipped' | 'delivered';
trackingNumber?: string;
deliveryDate?: Date;
cancellationReason?: string;
}
function processOrder(order: Order) {
if (order.status === 'shipped') {
console.log(order.trackingNumber); // Might be undefined!
}
}
The code compiles, but the logic is fragile. We assume trackingNumber exists for shipped orders, but TypeScript can't enforce that assumption.
A discriminated union makes the relationship explicit:
type Order =
| { id: string; status: 'pending' }
| { id: string; status: 'shipped'; trackingNumber: string }
| { id: string; status: 'delivered'; deliveryDate: Date }
| { id: string; status: 'cancelled'; cancellationReason: string };
function processOrder(order: Order) {
switch (order.status) {
case 'shipped':
console.log(order.trackingNumber); // Always exists
break;
case 'delivered':
console.log(order.deliveryDate); // Always exists
break;
case 'cancelled':
console.log(order.cancellationReason); // Always exists
break;
case 'pending':
// No extra properties
break;
}
}
Now TypeScript forces you to handle each case properly. The domain logic is clearer, and impossible states become unrepresentable.
Duplicate types instead of deriving them
When you write the same type twice, you've created a maintenance burden. Changes to one source of truth will diverge from the other, and bugs will follow.
We've seen this in API clients across multiple teams:
// Anti-pattern: types defined separately
interface UserRequest {
name: string;
email: string;
age: number;
}
interface UserResponse {
id: string;
name: string;
email: string;
age: number;
}
function createUser(user: UserRequest): Promise {
return fetch('/api/users', {
method: 'POST',
body: JSON.stringify(user)
}).then(r => r.json());
}
If the backend adds a field to the request, we have to remember to update both types. If we forget to update one, we lose type safety exactly where we need it.
Derive types from a single source instead:
interface UserBase {
name: string;
email: string;
age: number;
}
// Request is just the base
type UserRequest = UserBase;
// Response adds an id
interface UserResponse extends UserBase {
id: string;
}
function createUser(user: UserRequest): Promise {
return fetch('/api/users', {
method: 'POST',
body: JSON.stringify(user)
}).then(r => r.json());
}
Or use Pick, Omit, and Partial for more complex scenarios:
interface User {
id: string;
name: string;
email: string;
age: number;
createdAt: Date;
}
// Request omits id and createdAt
type UserRequest = Omit;
// Response is the full user
type UserResponse = User;
function createUser(user: UserRequest): Promise {
return fetch('/api/users', {
method: 'POST',
body: JSON.stringify(user)
}).then(r => r.json());
}
Now there's one source of truth. When the schema changes, both types stay in sync automatically.
Common mistakes
- Casting instead of narrowing: Using
asfeels faster than writing a guard function, but it trades immediate convenience for production risk. Write the guard once and reuse it. - Trusting "obvious" nullability: Non-null assertions often come from confidence that a value must exist. That confidence is usually correct—until it isn't. Always handle the null case explicitly or use a helper that makes the assertion clear.
- Assuming properties based on discriminator: When a union member should have a property, optional properties on the parent type invite bugs. Use discriminated unions to encode invariants.
- Creating types without a single source: If two types describe nearly the same thing, make one derive from the other. Duplication is a debt that compounds over time.
Key takeaways
ascasts and!assertions are escape hatches, not defaults. Use them sparingly and only when you've explicitly validated or made the assumption clear to the team.- Write type guard functions to validate data at boundaries—APIs, user input, external libraries. This moves trust from the compiler to runtime checks.
- Use discriminated unions to encode domain logic directly into types. They prevent impossible states and make code self-documenting.
- Derive types from a single source using
Omit,Pick,Partial, and inheritance. When the schema changes, all dependent types update automatically. - Type safety is a team practice, not an individual tool. Document why a cast or assertion exists so maintainers understand your reasoning.






Comments
Loading comments…