TypeScript Things That Will Surprise You
🤯 TypeScript Things That Will Surprise You — Deep Concepts Every Pro Developer Should Know
TypeScript often looks like “JavaScript with types.”
But once you go beyond interfaces, string, number, and boolean, TypeScript becomes a surprisingly powerful type-level programming language.
You start discovering things like:
- 🤯 Types that exist only at compile time
- 🧠 Functions that can determine types dynamically
- 🔥
neverbeing more useful than you expect - 🪄 Types that behave differently depending on context
- 🧩
unknownbeing safer thanany - 🎯 Literal types that can completely change API design
- 🧬 Conditional and mapped types
- 🛡️ Exhaustiveness checking
- ⚡ Structural typing
- 🧠 Type narrowing through control flow
- 💥 Situations where TypeScript intentionally allows unsafe behavior
Let’s explore the TypeScript concepts that can genuinely surprise you — and the principles professional developers should follow.
🧠 1. TypeScript Types Don’t Exist at Runtime
This is probably the first major surprise.
Consider:
interface User {
id: number;
name: string;
}
const user: User = {
id: 1,
name: "Lakhveer"
};
You might think JavaScript somehow knows that user is a User.
It doesn’t.
After compilation, the interface disappears.
The generated JavaScript is essentially:
const user = {
id: 1,
name: "Lakhveer"
};
There is no runtime User object.
Why?
TypeScript performs static analysis before your code runs.
Think of the process like this:
TypeScript
↓
Type Checking
↓
JavaScript Generation
↓
Browser / Node.js
Types help the compiler understand your code.
They aren’t normally runtime objects.
Professional principle 🧑💻
Use TypeScript to prevent invalid states during development, but never assume TypeScript validates external runtime data.
For example:
const response: User = await fetch("/api/user")
.then(res => res.json());
This does not guarantee that the server actually returned a valid User.
For external data, use runtime validation.
Libraries such as Zod can help:
const UserSchema = z.object({
id: z.number(),
name: z.string()
});
const user = UserSchema.parse(data);
TypeScript protects your code.
Runtime validation protects your application.
🤯 2. any Basically Turns TypeScript Off
Consider:
let value: any = "hello";
value.foo.bar.baz();
value();
value.notARealProperty;
TypeScript won’t complain about these operations.
Why?
Because any essentially tells TypeScript:
“Trust me. I know what I’m doing.”
And TypeScript stops checking many things.
That’s why this is dangerous:
function processUser(user: any) {
console.log(user.name);
}
You lose most of the benefits of TypeScript.
🛡️ 3. unknown Is the Safer any
Instead:
let value: unknown = "hello";
Now this fails:
value.foo;
You must narrow the type first.
if (typeof value === "string") {
console.log(value.toUpperCase());
}
Or:
if (typeof value === "number") {
console.log(value.toFixed(2));
}
This is a powerful distinction:
any
↓
"Trust me"
unknown
↓
"Prove it first"
Pro rule 🔥
Prefer:
unknown
over:
any
when dealing with data whose type you don’t know.
🧩 4. TypeScript Uses Structural Typing
This surprises developers coming from languages such as Java, C#, or C++.
Suppose:
interface User {
name: string;
}
const employee = {
name: "Lakhveer",
salary: 100000
};
const user: User = employee;
This works.
Why?
Because TypeScript doesn’t primarily care about the object’s declared identity.
It cares about its structure.
The object contains:
name: string
So it satisfies the User interface.
Think:
User requires:
name: string
employee has:
name: string
salary: number
Therefore:
employee satisfies User
Structural typing
Required properties
↓
Does object contain them?
↓
Yes
↓
Compatible
This makes TypeScript extremely flexible for large applications.
🤯 5. Excess Property Checking Is Different
Now here’s the interesting part.
This:
interface User {
name: string;
}
const user: User = {
name: "Lakhveer",
salary: 100000
};
may produce an error:
Object literal may only specify known properties
But:
const employee = {
name: "Lakhveer",
salary: 100000
};
const user: User = employee;
works.
Why?
TypeScript performs excess property checking on fresh object literals.
This isn’t exactly the same thing as structural compatibility.
Important lesson
Don’t think:
“TypeScript always rejects extra properties.”
It doesn’t.
🎯 6. Literal Types Are Extremely Powerful
You can define:
let direction: "left" | "right";
direction = "left";
But:
direction = "up";
fails.
You’re not saying:
direction: string
You’re saying:
direction must be EXACTLY:
"left"
OR
"right"
This can dramatically improve APIs.
For example:
type Environment =
| "development"
| "staging"
| "production";
Now:
function deploy(env: Environment) {
// ...
}
Invalid:
deploy("testing");
🪄 7. as const Can Completely Change Inference
Consider:
const colors = ["red", "blue", "green"];
TypeScript usually infers:
string[]
But:
const colors = ["red", "blue", "green"] as const;
creates a readonly tuple:
readonly ["red", "blue", "green"]
Now you can derive a union:
type Color = typeof colors[number];
Result:
type Color = "red" | "blue" | "green";
🔥 This pattern is incredibly useful.
Instead of duplicating:
type Color = "red" | "blue" | "green";
const colors: Color[] = [
"red",
"blue",
"green"
];
you can create the source of truth once:
const colors = [
"red",
"blue",
"green"
] as const;
type Color = typeof colors[number];
Pro principle
Avoid duplicating information when the type system can derive it.
🧠 8. typeof Has Two Different Meanings
In JavaScript:
typeof value
is a runtime operation.
Example:
typeof "hello";
returns:
"string"
But TypeScript also uses typeof at the type level:
const user = {
id: 1,
name: "Lakhveer"
};
type User = typeof user;
Now:
User
becomes:
{
id: number;
name: string;
}
Same keyword.
Different context.
Runtime:
typeof value
Type system:
typeof variable
🔥 9. keyof Turns Object Keys Into a Union
Suppose:
interface User {
id: number;
name: string;
email: string;
}
Then:
type UserKeys = keyof User;
produces:
"id" | "name" | "email"
Now create a safe getter:
function getValue<T, K extends keyof T>(
object: T,
key: K
) {
return object[key];
}
Usage:
const user = {
id: 1,
name: "Lakhveer"
};
getValue(user, "name");
Works.
But:
getValue(user, "salary");
fails.
You’ve created a type-safe dynamic property accessor.
🧬 10. Generics Are More Than Reusable Types
Many developers use generics like:
function identity<T>(value: T): T {
return value;
}
But generics become much more powerful when multiple values are connected.
function pair<T>(first: T, second: T): [T, T] {
return [first, second];
}
This ensures both values have the same inferred type.
pair(10, 20);
works.
But:
pair(10, "hello");
fails under appropriate constraints/inference.
You can also connect input and output:
function first<T>(items: T[]): T | undefined {
return items[0];
}
If:
const numbers = first([1, 2, 3]);
TypeScript knows:
number | undefined
🧠 11. Conditional Types Are Basically Type-Level if
Consider:
type IsString<T> =
T extends string
? true
: false;
Then:
type A = IsString<string>;
gives:
true
And:
type B = IsString<number>;
gives:
false
You are effectively writing:
if T is string
return true
else
return false
But this happens at the type level.
🤯 12. infer Lets TypeScript Extract Information
This is one of the most powerful TypeScript features.
Suppose:
type ReturnTypeOf<T> =
T extends (...args: any[]) => infer R
? R
: never;
Now:
function getUser() {
return {
id: 1,
name: "Lakhveer"
};
}
type User = ReturnTypeOf<typeof getUser>;
TypeScript extracts the return type.
Conceptually:
Function
↓
infer R
↓
Extract return type
This is how many advanced utility types work.
🛠️ 13. Mapped Types Can Transform Entire Types
Suppose:
interface User {
id: number;
name: string;
email: string;
}
You can create a readonly version:
type ReadonlyUser = {
readonly [K in keyof User]: User[K];
};
Or optional:
type OptionalUser = {
[K in keyof User]?: User[K];
};
You don’t have to manually rewrite:
id?: number;
name?: string;
email?: string;
TypeScript transforms the entire type.
🧩 14. Utility Types Are Built Using These Concepts
TypeScript provides utilities such as:
Partial<T>
Required<T>
Readonly<T>
Pick<T, K>
Omit<T, K>
Record<K, T>
Exclude<T, U>
Extract<T, U>
NonNullable<T>
ReturnType<T>
Parameters<T>
For example:
interface User {
id: number;
name: string;
email: string;
}
Use:
type UserPreview = Pick<User, "id" | "name">;
Result:
{
id: number;
name: string;
}
Or:
type UserWithoutEmail = Omit<User, "email">;
🤯 15. never Is Not Just “Nothing”
never represents a value that cannot exist.
For example:
function fail(message: string): never {
throw new Error(message);
}
The function never successfully returns.
But never becomes especially powerful with exhaustive checks.
Consider:
type Status =
| "loading"
| "success"
| "error";
Then:
function handleStatus(status: Status) {
switch (status) {
case "loading":
return "Loading";
case "success":
return "Success";
case "error":
return "Error";
default:
return assertNever(status);
}
}
function assertNever(value: never): never {
throw new Error("Unexpected value: " + value);
}
Now imagine someone adds:
type Status =
| "loading"
| "success"
| "error"
| "cancelled";
The compiler can tell you that your switch isn’t exhaustive.
🔥 This is extremely useful in large applications.
🛡️ 16. Type Narrowing Is Flow-Sensitive
TypeScript can understand how your code changes the possible type of a variable.
Example:
function print(value: string | number) {
if (typeof value === "string") {
console.log(value.toUpperCase());
} else {
console.log(value.toFixed(2));
}
}
Inside the first branch:
value
is known to be:
string
Inside the second:
number
TypeScript follows your control flow.
🧠 17. Custom Type Guards
You can teach TypeScript how to recognize your own types.
interface User {
name: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"name" in value
);
}
Then:
const data: unknown = getData();
if (isUser(data)) {
console.log(data.name);
}
The compiler now understands:
data: User
inside the block.
🎯 18. Discriminated Unions Are Perfect for State Machines
Instead of:
interface State {
status: string;
data?: User;
error?: string;
}
use:
type State =
| {
status: "loading";
}
| {
status: "success";
data: User;
}
| {
status: "error";
error: string;
};
Now:
function render(state: State) {
switch (state.status) {
case "loading":
return "Loading...";
case "success":
return state.data.name;
case "error":
return state.error;
}
}
TypeScript understands which properties exist in each state.
This prevents impossible combinations such as:
status = success
error = "Something went wrong"
Pro principle 🧠
Model valid states instead of allowing invalid states and checking them everywhere.
🔥 19. satisfies Is Different From as
Consider:
const config = {
port: 3000,
host: "localhost"
} satisfies {
port: number;
host: string;
};
satisfies checks compatibility without unnecessarily changing the inferred type of the expression.
This is particularly useful for configuration objects.
Compare that with:
const config = {...} as Config;
as is an assertion.
You’re essentially telling TypeScript:
“Treat this as this type.”
satisfies is more like:
“Verify that this conforms to this type.”
Professional preference
When your goal is validation rather than overriding the compiler, consider:
satisfies
instead of:
as
⚡ 20. readonly Doesn’t Automatically Mean Deeply Immutable
Consider:
type User = {
readonly name: string;
readonly address: {
city: string;
};
};
You can’t do:
user.name = "New Name";
But:
user.address.city = "Indore";
is still allowed.
Why?
Because readonly applies only to the property itself.
It doesn’t recursively freeze nested objects.
For deep immutability, you need a recursive type or runtime mechanism.
🤯 21. Type Assertions Don’t Perform Conversion
This:
const value = "123" as unknown as number;
doesn’t convert the string into a number.
At runtime:
typeof value
is still:
string
If you want conversion:
const value = Number("123");
Type assertions affect the compiler.
They don’t magically transform JavaScript values.
🧠 22. Optional Property ≠ undefined in Every Context
Consider:
interface User {
name?: string;
}
This generally means the property can be absent.
Conceptually:
{}
is valid.
And:
{
name: "Lakhveer"
}
is valid.
But depending on your TypeScript configuration, explicitly assigning:
{
name: undefined
}
can have different semantics.
The compiler option:
{
"exactOptionalPropertyTypes": true
}
makes the distinction stricter.
This matters when designing APIs where:
property missing
and
property explicitly undefined
have different meanings.
🧬 23. Template Literal Types Can Build APIs
TypeScript can construct string types.
Example:
type EventName =
`user:${"created" | "updated" | "deleted"}`;
This produces:
"user:created"
"user:updated"
"user:deleted"
You can build powerful APIs with this.
For example:
type HttpMethod = "GET" | "POST";
type Endpoint =
`${HttpMethod} /users`;
Now valid values include:
"GET /users"
"POST /users"
🔥 This is type-level string manipulation.
🚀 24. TypeScript Can Compute Types
You can combine:
keyof
typeof
conditional types
mapped types
template literals
infer
generics
to create types that are effectively computed.
For example:
type EventMap = {
userCreated: {
id: number;
};
userDeleted: {
id: number;
};
};
You could build a type-safe event emitter:
class EventEmitter<Events extends Record<string, unknown>> {
on<K extends keyof Events>(
event: K,
callback: (payload: Events[K]) => void
) {
// ...
}
emit<K extends keyof Events>(
event: K,
payload: Events[K]
) {
// ...
}
}
Now:
const emitter =
new EventEmitter<EventMap>();
emitter.emit("userCreated", {
id: 1
});
But:
emitter.emit("userCreated", {
name: "Lakhveer"
});
fails.
The compiler understands the relationship between the event name and payload.
🧠 25. TypeScript Can Be More Strict Than JavaScript — But Not Perfect
TypeScript intentionally doesn’t try to eliminate every possible runtime error.
JavaScript is highly dynamic.
For example:
const numbers = [1, 2, 3];
const value = numbers[100];
Depending on compiler settings, this can be typed in a way that doesn’t fully communicate the possibility of undefined.
That’s where:
{
"noUncheckedIndexedAccess": true
}
becomes valuable.
Now:
numbers[100]
can be treated as:
number | undefined
This encourages safer code.
🛡️ 26. Strict Mode Is One of the Best Decisions You Can Make
A professional TypeScript project should generally start with:
{
"compilerOptions": {
"strict": true
}
}
This enables a collection of stronger checks.
Among them:
strictNullChecks
noImplicitAny
strictFunctionTypes
strictPropertyInitialization
useUnknownInCatchVariables
Instead of slowly adding safety after bugs occur, make the compiler your first line of defense.
💎 27. TypeScript’s Best Feature Is Often Its Ability to Prevent Invalid States
Imagine an API request:
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: string };
Compare this with:
interface RequestState {
loading: boolean;
data?: User;
error?: string;
}
The second design allows nonsense:
{
loading: true,
data: user,
error: "Something failed"
}
The first design makes those combinations impossible.
This is one of the most important TypeScript design principles:
Make invalid states unrepresentable.
👨💻 Principles to Always Follow as a Pro TypeScript Developer
Now let’s move beyond syntax.
Knowing TypeScript features doesn’t automatically make someone a professional TypeScript developer.
The real difference is how you design systems.
🥇 Principle 1: Don’t Use any as an Escape Hatch
Bad:
function process(data: any) {
// ...
}
Better:
function process(data: unknown) {
// validate/narrow first
}
Even better:
function process(data: User) {
// ...
}
Use any only when you genuinely understand the trade-off.
🥈 Principle 2: Prefer Inference When It’s Obvious
Don’t write unnecessary types everywhere.
Instead of:
const name: string = "Lakhveer";
const age: number = 28;
prefer:
const name = "Lakhveer";
const age = 28;
TypeScript already knows.
Explicit types are most valuable when they communicate an important contract.
🥉 Principle 3: Types Should Explain Intent
Bad:
function process(value: string) {}
Better:
type UserId = string;
function processUser(userId: UserId) {}
Good types aren’t only about preventing errors.
They communicate meaning.
🧠 Principle 4: Keep Types Close to the Domain
Instead of:
function createOrder(
userId: string,
productId: string,
status: string
) {}
define meaningful types:
type OrderStatus =
| "pending"
| "paid"
| "cancelled";
function createOrder(
userId: UserId,
productId: ProductId,
status: OrderStatus
) {}
Now the compiler understands your business domain.
🔥 Principle 5: Prefer Unions Over Boolean Explosion
Avoid:
interface State {
loading: boolean;
success: boolean;
error: boolean;
}
This creates impossible combinations.
Prefer:
type State =
| "loading"
| "success"
| "error";
Or a discriminated union when additional data is required.
🛡️ Principle 6: Validate Data at System Boundaries
TypeScript can’t protect you from:
API
Database
User input
Environment variables
Files
Third-party services
JSON
Local storage
Treat these as untrusted boundaries.
Use:
External data
↓
Runtime validation
↓
Trusted typed data
↓
Application logic
This is one of the most important architectural patterns for TypeScript applications.
🎯 Principle 7: Don’t Overengineer the Type System
You can create extremely complicated types.
That doesn’t mean you should.
If your type looks like:
type Something<T, U, V, X extends ...> = ...
and nobody understands it six months later, you’ve created technical debt.
Remember:
The type system exists to make software easier to understand and maintain.
Not to demonstrate how clever you are.
🚀 Principle 8: Use Types to Improve APIs
A good API should make the correct usage easy.
Instead of:
createUser(
"Lakhveer",
"admin",
true,
false,
undefined
);
prefer an object:
createUser({
name: "Lakhveer",
role: "admin",
active: true
});
And type it properly.
Good TypeScript APIs should provide excellent autocomplete and compiler feedback.
🧩 Principle 9: Prefer Composition Over Giant Interfaces
Instead of one enormous interface:
interface User {
// 50 properties
}
consider composing smaller concepts:
type Identifiable = {
id: string;
};
type Timestamped = {
createdAt: Date;
updatedAt: Date;
};
type User =
Identifiable &
Timestamped & {
name: string;
};
This can make complex domains easier to reason about.
🧪 Principle 10: Let the Compiler Be Part of Your Testing Strategy
TypeScript isn’t a replacement for tests.
But it can catch entire categories of bugs before tests run.
For example:
type PaymentStatus =
| "pending"
| "paid"
| "failed";
When you add:
"refunded"
the compiler can reveal every place where your application forgot to handle it.
That’s powerful.
🏗️ Principle 11: Keep Runtime and Compile-Time Thinking Separate
Always ask:
Compile time?
interface User {
id: number;
}
Runtime?
if (typeof value === "object") {
}
External runtime validation?
UserSchema.parse(data);
Understanding this separation prevents many TypeScript misconceptions.
🔍 Principle 12: Read the Generated JavaScript
When something feels confusing, ask:
“What JavaScript will this become?”
TypeScript eventually runs as JavaScript.
Understanding both layers makes you a much stronger developer.
📚 Principle 13: Learn JavaScript Deeply
TypeScript doesn’t replace JavaScript knowledge.
A professional TypeScript developer should deeply understand:
Closures
Promises
Event Loop
Prototypes
this
Modules
Destructuring
Async/Await
Objects
Arrays
Functions
Hoisting
Scopes
Because TypeScript sits on top of JavaScript.
⚡ Principle 14: Keep tsconfig.json Strict and Intentional
Don’t blindly copy a configuration.
Understand options such as:
{
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true
}
Every compiler option changes the safety/ergonomics trade-off of your codebase.
🧠 The TypeScript Mental Model
If you want to become really good at TypeScript, think in layers:
TypeScript
│
┌────────────┴────────────┐
│ │
JavaScript Type System
│ │
Runtime Behavior Compile-Time Safety
│ │
Browser / Node Type Relationships
│
┌──────────┼──────────┐
│ │ │
Generics Unions Inference
│ │ │
keyof never conditional
│ │ │
mapped guards infer
│ │ │
└──────────┴──────────┘
Once you understand this model, TypeScript stops feeling like a collection of random syntax rules.
You start seeing it as a language for describing relationships between values.
🌟 Final Takeaway
TypeScript isn’t simply:
JavaScript + types.
It’s a powerful system for expressing:
- What values are allowed
- How objects relate to each other
- What states are possible
- What functions accept and return
- How data flows through your application
- Which operations are safe
- Which cases haven’t been handled
The most important TypeScript features to master are not necessarily the flashy ones.
Master:
Generics
Unions
Narrowing
Inference
keyof
typeof
Mapped Types
Conditional Types
infer
Discriminated Unions
never
unknown
satisfies
Template Literal Types
But more importantly, develop the mindset to model your domain correctly.
Because the goal isn’t to write the most complicated TypeScript.
The goal is to write software where the compiler helps you answer:
“Can this state actually happen?”
And when your type system can answer that question before your application even runs, you’ve started using TypeScript like a professional. 🚀💻
💡 One Rule to Remember
Don’t use TypeScript merely to describe your code. Use TypeScript to design safer code.
That’s where TypeScript goes from being a JavaScript convenience to becoming a serious engineering tool. 🔥
#TypeScript #JavaScript #WebDevelopment #Programming #SoftwareEngineering #FrontendDevelopment #BackendDevelopment #React #NodeJS #NextJS #Coding #Developer #100DaysOfCode #TechTips #ProgrammingTips #TypeSafety #CleanCode #DevCommunity
© Lakhveer Singh Rajput - Blogs. All Rights Reserved.