10 TypeScript Tricks That Made Me Mass-Delete Type Assertions From Our Codebase
Source: Dev.to
Introduction
Our codebase originally had 247 instances of as any. After learning a handful of TypeScript patterns, I was able to replace all but 3 of them with proper type safety. The remaining three are required only when interfacing with a genuinely untyped third‑party library.
Below are 10 patterns that made as any almost unnecessary.
1️⃣ Discriminated Unions – No Runtime Checks Needed
❌ Before
type Shape = {
kind: string;
radius?: number;
width?: number;
height?: number;
};
function area(shape: Shape): number {
if (shape.kind === 'circle')
return Math.PI * shape.radius! * shape.radius!;
if (shape.kind === 'rect')
return shape.width! * shape.height!;
throw new Error('Unknown shape');
}
✅ After
type Circle = { kind: 'circle'; radius: number };
type Rectangle = { kind: 'rectangle'; width: number; height: number };
type Shape = Circle | Rectangle;
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius * shape.radius;
case 'rectangle':
return shape.width * shape.height;
// ← TypeScript will error if a case is missing (with --strict)
}
}
No type assertions, no runtime checks, no ! non‑null assertions.
TypeScript narrows the type automatically based on the discriminant.
Use this for: API responses with different shapes, state machines, event handlers, multi‑step form wizards.
2️⃣ Preserve Literal Types with as const
❌ Before
const ROLES = ['admin', 'editor', 'viewer'];
// type: string[]
✅ After
const ROLES = ['admin', 'editor', 'viewer'] as const;
// type: readonly ['admin', 'editor', 'viewer']
type Role = typeof ROLES[number]; // 'admin' | 'editor' | 'viewer'
function hasRole(user: User, role: Role): boolean {
/* … */
}
hasRole(user, 'admin'); // ✅
hasRole(user, 'superuser'); // ❌ Type error!
No enum is needed—the array is the single source of truth, and the type is derived automatically.
3️⃣ Template Literal Types for Exhaustive Endpoints
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type APIVersion = 'v1' | 'v2';
type Endpoint = `/${APIVersion}/${'users' | 'orders' | 'products'}`;
// type: '/v1/users' | '/v1/orders' | '/v1/products' | '/v2/users' | ...
function apiCall(method: HTTPMethod, endpoint: Endpoint): Promise {
return fetch(`https://api.example.com${endpoint}`, { method });
}
apiCall('GET', '/v1/users'); // ✅
apiCall('GET', '/v3/users'); // ❌ Type error
apiCall('PATCH', '/v1/users'); // ❌ Type error
The compiler guarantees only valid HTTP methods, API versions, and resource names can be used.
4️⃣ satisfies – Validate and Preserve Specific Types
type Config = {
port: number;
host: string;
features: Record;
};
// ❌ Using a plain annotation widens literals:
const config: Config = {
port: 3000,
host: 'localhost',
features: { darkMode: true, newCheckout: false },
};
config.port; // number (not 3000)
// ✅ Using `satisfies` keeps literals:
const config = {
port: 3000,
host: 'localhost',
features: { darkMode: true, newCheckout: false },
} satisfies Config;
config.port; // 3000 (literal)
config.features.darkMode; // true (literal)
config.features.typo; // ❌ Type error – catches misspellings!
satisfies validates the shape without widening the type, giving you the best of both worlds.
5️⃣ Branded (Nominal) Types – Prevent Mixing IDs
// The bug: passing a userId where an orderId is expected
function getOrder(orderId: string): Order { /* … */ }
getOrder(userId); // No compile‑time error – both are strings
// Fix: branded types
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
function userId(id: string): UserId { return id as UserId; }
function orderId(id: string): OrderId { return id as OrderId; }
function getOrder(id: OrderId): Order { /* … */ }
const user = userId('u_123');
const order = orderId('o_456');
getOrder(order); // ✅
getOrder(user); // ❌ Type error
getOrder('raw'); // ❌ Type error
Zero runtime cost—the brand exists only in the type system and catches an entire class of bugs at compile time.
6️⃣ Handy Utility Types
Extract the resolved type of an async function
type AsyncReturnType = T extends (...args: any[]) => Promise
? R
: never;
async function fetchUser(id: string): Promise {
/* … */
}
type User = AsyncReturnType;
// User = { name: string; email: string }
Extract the element type of an array
type ElementOf = T extends readonly (infer E)[] ? E : never;
type Item = ElementOf; // type of a single item
Enforce exhaustive mappings with Record
type Status = 'pending' | 'active' | 'suspended' | 'deleted';
const statusLabels: Record = {
pending: 'Pending Review',
active: 'Active',
suspended: 'Temporarily Suspended',
deleted: 'Permanently Deleted',
};
const statusColors: Record = {
pending: '#FFA500',
active: '#00FF00',
suspended: '#FF0000',
// ❌ Property 'deleted' is missing → compile‑time error
};
If you add a new status to the union, TypeScript forces you to add it everywhere the Record is used.
7️⃣ Deriving Types Directly from Zod Schemas
import { z } from 'zod';
// Define the schema once:
const UserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
age: z.number().int().positive().optional(),
role: z.enum(['admin', 'editor', 'viewer']),
});
// Infer the TypeScript type automatically:
type User = z.infer;
// User = { name: string; email: string; age?: number; role: 'admin' | 'editor' | 'viewer' }
// Runtime validation:
const result = UserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.format() });
}
const user: User = result.data; // Fully typed & validated
No duplicate type definitions—the schema is the type. Changing the schema updates both runtime validation and static typing.
8️⃣ Typed pick Helper – Preserve Property Types
// ❌ Before – loses type information:
function pick(obj: any, keys: string[]): any {
return Object.fromEntries(keys.map(k => [k, obj[k]]));
}
// ✅ After – generic, type‑safe version:
function pick(obj: T, keys: readonly K[]): Pick {
return keys.reduce((acc, key) => {
acc[key] = obj[key];
return acc;
}, {} as Pick);
}
// Example usage:
type Person = { id: number; name: string; age: number };
const person: Person = { id: 1, name: 'Alice', age: 30 };
const nameOnly = pick(person, ['name']);
// nameOnly: { name: string }
The generic version retains the exact property types and gives you proper IntelliSense.
Closing Thoughts
By adopting these patterns you can dramatically reduce the need for as any, improve compile‑time safety, and keep your codebase more maintainable. The only remaining as any usages should be isolated to truly untyped third‑party integrations. Happy typing!
function pick(obj: T, keys: K[]): Pick {
return Object.fromEntries(keys.map(k => [k, obj[k]])) as Pick;
}
const user = { name: 'Jane', email: 'jane@test.com', password: 'secret' };
const safe = pick(user, ['name', 'email']);
// Type: { name: string; email: string }
// 'password' is excluded from the type **AND** the runtime object
pick(user, ['name', 'typo']); // ❌ Type error: 'typo' not in keyof User
type ApiResponse =
| { status: 'success'; data: User }
| { status: 'error'; error: string };
// Type predicate: narrows the type in the calling code
function isSuccess(res: ApiResponse): res is { status: 'success'; data: User } {
return res.status === 'success';
}
const response = await fetchUser(id);
if (isSuccess(response)) {
console.log(response.data.name); // TypeScript knows `data` exists
} else {
console.log(response.error); // TypeScript knows `error` exists
}
as any Audit
After applying these patterns, I searched our codebase:
Before
$ grep -r "as any" src/ | wc -l
247
After
$ grep -r "as any" src/ | wc -l
3
Each as any was a place where we told TypeScript: “Trust me, I know what I’m doing.” In 244 of 247 cases, we were wrong — there was a type‑safe way to express what we wanted.
Discussion
- What’s your most‑used TypeScript trick?
- What’s the weirdest type you’ve ever written?
I love seeing creative type‑level programming. Comments open!
💡 Want to get more out of AI coding tools?
I put together an AI Coding Prompts Pack — 50+ battle‑tested prompts, 6 Cursor rules files, and Claude Code workflow templates. $9 with lifetime updates.