Stop Fighting the TypeScript Compiler and Start Writing Safer Code
How unknown, satisfies, custom type guards, and template literal types can dramatically improve the safety of your code.
TypeScript’s type system is incredibly powerful. In theory, it can catch a huge number of bugs before your code ever runs.
In practice, though, many developers barely use a fraction of what it offers.
Be honest — most of us have written any at least once just to silence the compiler and get the project to build.
The problem is that shortcuts like this tend to come back later as runtime crashes.
In this article, we’ll walk through several practical TypeScript techniques that help you write cleaner, safer, and more predictable code, while letting the type system catch bugs before they reach production.
First Rule: Forget About any
When you assign the any type to a variable, you’re essentially telling the compiler:
“Stop checking this. I’ll handle it myself.”
At that moment, you’ve basically turned off TypeScript.
Even worse, any tends to spread through a codebase. If a function returns any, every variable that receives that value becomes untyped as well.
Instead, use unknown.
It behaves like a safer version of any. You can store anything in an unknown variable, but TypeScript won’t let you access properties or call methods until you explicitly check the value.
Example:
// Dangerous: the compiler allows everything
const rawData: any = JSON.parse(userInput);
rawData.toLowerCase(); // crashes if rawData is a numberA safer version:
const safeData: unknown = JSON.parse(userInput);
// safeData.toLowerCase(); ❌ Compile error
if (typeof safeData === “string”) {
console.log(safeData.toLowerCase());
}The unknown type works especially well for:
API responses
user input
error objects in
catchblocks
These are situations where you can never be completely sure about the data shape.
Use satisfies Instead of as
The as keyword is a type assertion. It forces TypeScript to trust you.
Sometimes that’s necessary — but it can also hide real errors.
TypeScript 4.9 introduced a better tool: satisfies.
Instead of blindly accepting your assertion, it checks whether the object actually matches the expected type.
Example:
type Status = “draft” | “published” | “archived”;
type Article = {
title: string;
status: Status;
tags: string[];
};Using as:
const badArticle = {
title: “TS Tips”,
status: “published”
} as Article;This compiles even though the tags field is missing.
Now compare it with satisfies:
const safeArticle = {
title: “TS Tips”,
status: “published”
} satisfies Article;TypeScript immediately reports the problem.
This already makes satisfies safer than as, but there’s another advantage.
satisfies Keeps Precise Types
When you type an object normally, TypeScript often widens literal values.
Example:
const articleA: Article = {
title: “TS”,
status: “published”
};The status field now has type:
‘draft’ | ‘published’ | ‘archived’TypeScript forgets that the actual value is "published".
But with satisfies, the literal value is preserved.
const articleB = {
title: “TS”,
status: “published”
} satisfies Article;Now the type of articleB.status is exactly "published".
Why does this matter?
Because TypeScript can now detect impossible conditions.
if (articleB.status === “draft”) {
// TypeScript warns: this condition will never be true
}You get stricter validation and better autocomplete at the same time.
Write Your Own Type Guards
Simple checks like typeof or instanceof are often not enough.
When working with complex objects, TypeScript allows you to define custom type guards.
These are functions that tell the compiler what type a value has when the function returns true.
Example:
interface PaymentSuccess {
status: “success”;
transactionId: string;
}
interface PaymentFailed {
status: “error”;
errorMessage: string;
}
type PaymentResult = PaymentSuccess | PaymentFailed;Now we define a custom type guard:
function isSuccess(result: PaymentResult): result is PaymentSuccess {
return result.status === “success”;
}And use it like this:
function handlePayment(result: PaymentResult) {
if (isSuccess(result)) {
console.log(`Success! ID: ${result.transactionId}`);
} else {
console.error(`Error: ${result.errorMessage}`);
}
}TypeScript automatically narrows the type inside each branch.
Type guards are especially powerful with array methods:
const successfulPayments = results.filter(isSuccess);The resulting array automatically becomes:
PaymentSuccess[]Avoid Enums in Favor of Union Types
Developers coming from languages like Java or C# often reach for enum.
But TypeScript enums have a hidden downside.
Most TypeScript types disappear during compilation. The compiler strips them away and outputs clean JavaScript.
Enums are different — they become actual runtime code.
Example:
enum Role {
Admin,
Editor
}This compiles into something like:
var Role;
(function (Role) {
Role[Role[”Admin”] = 0] = “Admin”;
Role[Role[”Editor”] = 1] = “Editor”;
})(Role || (Role = {}));This adds unnecessary code to your bundle.
There’s another historical issue: numeric enums allowed assigning arbitrary numbers.
A simpler alternative is string unions.
type UserRole = “admin” | “editor” | “viewer”;
function setRole(role: UserRole) {}
setRole(”admin”); // OK
// setRole(”owner”); // ErrorYou get:
autocomplete
compile-time validation
zero runtime cost
What If You Need to Iterate Over Roles?
Union types cannot be iterated directly.
The solution is to create a constant object and freeze it with as const.
const ROLES = {
Admin: “admin”,
Editor: “editor”,
Viewer: “viewer”
} as const;Then extract the union from the object:
type AppRole = typeof ROLES[keyof typeof ROLES];Now you get both:
a runtime object for loops
a strictly typed union for TypeScript
Use Record for Dictionaries
Sometimes developers type objects as Object or {}.
Both are problematic.
Objectallows almost anything, including primitives.{}means “any value except null and undefined”.
When describing key-value structures, use Record.
Example:
type Config = Record<string, unknown>;
const config: Config = {
url: “localhost”,
port: 8080
};Record becomes even more useful when keys are limited.
type Role = “admin” | “user” | “guest”;
const permissions: Record<Role, string[]> = {
admin: [”read”, “write”, “delete”],
user: [”read”, “write”],
guest: [”read”]
};If you forget a role or add an extra key, TypeScript will immediately complain.
Generate Types with Template Literal Types
TypeScript also supports template literal types, which allow you to build types dynamically from strings.
Example:
type ButtonSize = “small” | “medium” | “large”;
type ButtonTheme = “primary” | “secondary”;
type ButtonClass = `btn-${ButtonSize}-${ButtonTheme}`;TypeScript generates all possible combinations:
btn-small-primary
btn-small-secondary
btn-medium-primary
...Usage:
const myClass: ButtonClass = “btn-medium-primary”;Incorrect values will immediately produce errors.
Transform Strings at the Type Level
TypeScript also provides built-in utilities for string manipulation:
CapitalizeLowercaseUppercase
Example:
type EventType = “click” | “hover” | “scroll”;
type EventHandler = `on${Capitalize<EventType>}`;This automatically generates:
onClick | onHover | onScrollFinal Thoughts
Writing good TypeScript code isn’t about covering every line with types just for the sake of it.
It’s about using the right tools where they actually help.
Avoid shortcuts like any. Let the compiler guide you instead of fighting it.
When used properly, TypeScript becomes less of an obstacle and more of a powerful safety net that catches bugs long before they reach production.


