🚧 The Real-World Problem: Runtime Exceptions
"Cannot read property ‘x’ of undefined."
Almost every JavaScript developer has seen this error — often at the worst possible time. That’s because JavaScript is flexible at compile time but unforgiving at runtime.
TypeScript introduces a compile-time safety net. But in the real world, especially when working with unpredictable APIs, we still encounter data that’s uncertain or incomplete.
This article explores how to use TypeScript's compiler tools and runtime strategies to safely handle unknown or unexpected data — so your code doesn’t just compile, but also survives in production.
**
🚩 The First Instinct: Use any
**
const data: any = getFromAPI();
console.log(data.user.name); // ⚡ might throw!
'any' removes all type checks — you can access any method or property later, and TS won’t stop you. Your code compiles, but it might explode at runtime.
It is very attractive to use any especially when compiler errors are everywhere and you want to see at least some of your code work. But you still want to explore benefits of TS making you code more stable and safe. Lets explore alternatives.
🔐 Prefer unknown over any
function handle(input: unknown) {
if (typeof input === 'string') {
console.log(input.toUpperCase());
}
}
Use 'unknown' when you don’t know the type yet. It forces you to narrow before using it. 'any' skips all checks, 'unknown' makes you prove it’s safe.
So in case you do not know the exact type of data. 'unknown' doesn’t allow you use its methods or properties unless you checked they exist.
🎯 Let the Compiler Help You
TypeScript’s greatest strength is static type checking — it checks your code before it runs. You tell it what types values should have, and it ensures everything matches.
TypeScript’s static type checking shines when you give it strong, precise definitions. Here are some common ways to express different data structures:
- Primitive types like string, number, and boolean — e.g., a user's name, age, or account status
- Union types like Success | Error — for values that can be in one of several valid shapes
- Record types like Record — for mapping keys (like user IDs) to values (like scores)
- Tuple types like [number, number] — for fixed-length pairs, such as coordinates
- Array types like string[] or Array — for lists of items
- Object types like { id: number; name: string } — to define expected shapes directly
- *Interfaces *— useful for object-oriented patterns and reusable data structures
- Literal types like type Role = 'admin' | 'user' — to restrict a value to a specific set of strings
With these tools, you clearly communicate your expectations, and TypeScript helps enforce them.
type Superpower = 'flight' | 'invisibility' | 'strength';
type Hero = 'IronMan' | 'WonderWoman' | 'Flash';
const powers: Record<Hero, Superpower> = {
IronMan: 'flight',
WonderWoman: 'strength',
Flash: 'invisibility',
};
✅ Inform the compiler how to interpret your data('satisfies' and 'as')
Union types are great for representing values that could take on different forms. But what if you know a value is a specific type — say, a WonderWoman — and want to treat it that way? TypeScript won’t let you access properties unless they exist on every possible type in the union.
That’s where 'as' comes in. You can use a type assertion to tell TypeScript, “Trust me, this is a WonderWoman”:
const hero = getHero(); // hero: Ironman | WonderWoman
(hero as WonderWoman).lasso(); // 👈 This bypasses safety checks
const nextHeroNumber = hero as number; // ❌ Error — TypeScript
// won’t allow casting between unrelated types like union of objects // to number.
The 'as' keyword in TypeScript is a type assertion, not a type conversion. It tells the compiler, "Trust me, I know what I'm doing" — but it doesn't actually change the value at runtime.
_> For example, writing "123" as number won't turn a string into a number — TypeScript will still flag this as an error because the types are fundamentally incompatible. So while as overrides some checks, it still respects basic type safety and won't let you assert totally unrelated types.
_
const config = {
env: 'production',
debug: false,
} satisfies { env: string; debug: boolean };
The satisfies operator is newer and safer. It lets you assert that a value matches a specific type — without changing its inferred shape.
It checks that:
The value matches the structure of the type
But still preserves literal types (like 'production' instead of just string)
❌ never: Exhaustive check and Exclude types
The never type represents values that never occur. It’s used to indicate that something is impossible or unreachable in your code.
When you use a switch or conditional statements with union types, TypeScript can enforce that all possible cases are handled by assigning the never type to the “default” case:
type Shape = { kind: 'circle' } | { kind: 'square' };
function draw(shape: Shape) {
switch (shape.kind) {
case 'circle':
break;
case 'square':
break;
default:
const _exhaustiveCheck: never = shape; // ❌ if a case is missing, TS complains
}
}
Here, if you forget to handle a new shape (like 'triangle'), TypeScript complains because the default case expects never — meaning it should never happen.
You can use never in conditional types to filter out types from unions:
type Exclude<T, U> = T extends U ? never : T;
type Clean = Exclude<'circle' | 'square' | 'triangle', 'circle'>; // 'square' | 'triangle'
// Example with literal:
type OnlyShapes<T> = T extends 'shape' ? never : T;
type Result = OnlyShapes<'shape' | 'color'>; // 'color'
This means: For each type T, if it extends U (the type to exclude), replace it with never (remove it), otherwise keep it.
⚠️ How to handle null and undefined?
Sometimes we need to define values that could be set to 'null' or undefined, like optional variables that were not used. Lets see what TS has for us, obviously it wouldnt allow to use these values as normal.
The obvious solution is to check! Then you can use the variable as you wish.
if (value == null) {
// true for both null and undefined
}
Optional chaining (?.) is a concise syntax that allows you to safely access nested properties or methods on objects that might be null or undefined. Instead of causing a runtime error, it short-circuits and returns undefined if any part of the chain is missing, making your code more robust and easier to read.
user?.profile?.name;
On the other hand, the non-null assertion operator (!) tells TypeScript that you are certain a value is neither null nor undefined at that point, effectively overriding strict null checks.
While this can reduce unnecessary checks, it should be used cautiously, as incorrect assumptions may lead to runtime errors. Together, these operators help manage uncertain data while balancing safety and developer convenience.
user!.login(); // use only if you're 100% sure
🎬 Runtime Type Checks
In real-world projects, values often differ from our expectations—particularly when sourced from APIs or user input. TypeScript performs static analysis during compilation, but it cannot guarantee that runtime data conforms to the defined types. Therefore, some type validations can only be performed at runtime. This is where type guards become essential.
You need runtime checks to confirm the shape and type of your data:
- typeof for primitives
- Array.isArray() for arrays
- instanceof for class instance
- Custom guards using is (type predicates)
They help narrow down from 'unknown' or any to something safe.
🔍 'typeof' operator
Use 'typeof' when you want to narrow primitive types safely before doing anything risky with the value — especially if it's coming from an unknown or dynamic source.
It returns a string that tells you the general category:
- For strings like "hello", it returns 'string'
- For numbers like 42, it returns 'number'
- For booleans like true, it returns 'boolean'
- For functions, it returns 'function'
- For arrays, objects, or even null, it returns 'object'
- For variables not assigned a value yet, it returns 'undefined'
> But be careful: typeof null is also 'object', which is a long-standing JavaScript quirk. So, always combine it with a null check like this:
if (typeof value === 'object' && value !== null) {
// ✅ Safe: It's a real object, not null
}
🛡️ Custom Guards with 'is' (Bridge between TS and JS)
If this returns true, treat this value as this type.
Сustom type guards are functions you write to perform runtime checks in JavaScript, but with a special TypeScript syntax (value is Type)
This connects JavaScript’s dynamic checks with TypeScript’s static system by:
Letting you verify data shapes or types at runtime (the JavaScript part)
Informing TypeScript about the narrowed type after the check (the static typing part)
function isUser(value: any): value is { id: number; name: string } {
return (
typeof value === 'object' &&
value !== null &&
typeof value.id === 'number' &&
typeof value.name === 'string'
);
}
Here, the function does real JavaScript checks, and TypeScript uses its special return type to know that inside an if (isUser(value))
block, value is safely a User.
🔍 Use 'in' to Narrow Union Types
The main use of 'in' in TypeScript is as a type guard. It answers:
“Does this object have this property?”
If yes, TypeScript narrows the type accordingly.
type Dog = { kind: 'dog'; bark: () => void };
type Cat = { kind: 'cat'; meow: () => void };
function speak(pet: Dog | Cat) {
if ('bark' in pet) {
pet.bark();
} else {
pet.meow();
}
}
✅ At runtime: 'key' in obj checks whether the property actually exists on the object — this is real JavaScript behavior.
✅ At compile time: TypeScript sees 'key' in obj and narrows the union type — helping you write safer code by knowing exactly which variant of a type you're dealing with.
🧪 Bonus: Validate with Zod or io-ts (Next Article Teaser)
import { z } from 'zod';
const User = z.object({ id: z.number(), name: z.string() });
const parsed = User.safeParse(input);
if (parsed.success) {
parsed.data.name; // ✅ safe
}
Zod lets you define and enforce runtime validation schemas — a powerful complement to TypeScript’s static checks.
🧠 Final Advice: Combine Compiler and Runtime Checks
Let TypeScript infer and check statically
Use guards to validate dynamically
Don’t trust outside data blindly
The type system is here to help — lean on it early, refine later.
Originally I posted this rticle on my Linkedin link
Top comments (0)