This article is published in English.
TypeScript Behaviors That Surprise Experienced Developers, and Why
Structural typing, excess property checks, as const, satisfies, conditional and mapped types, plus the design principles that turn them into safer code.
Most developers start by treating TypeScript as JavaScript with annotations, and then run into behavior that does not fit that picture: an object with extra fields is accepted in one place and rejected in another, a type assertion "converts" nothing, and readonly still lets a nested value change. Beneath the basic annotations sits a type-level language with its own rules for compatibility, inference and computation. This guide explains those surprises one by one, from runtime erasure and structural typing to infer, template literal types and satisfies, and then turns them into practical design principles you can apply to a real codebase.
Types stop existing when the code runs
Here is an interface and an object annotated with it:
interface User {
id: number;
name: string;
}
const user: User = {
id: 1,
name: "Lakhveer"
};
It is tempting to think the running program knows user is a User. It does not. Compilation removes the interface, and what remains is essentially this:
const user = {
id: 1,
name: "Lakhveer"
};
No User value exists at runtime. TypeScript performs static analysis first, then emits JavaScript, and only that JavaScript reaches the engine:
TypeScript
↓
Type Checking
↓
JavaScript Generation
↓
Browser / Node.js
Types inform the compiler; they are not runtime objects. The practical consequence is that TypeScript never validates data arriving from outside your program. The annotation below only asserts what the server returns; nothing checks it:
const response: User = await fetch("/api/user")
.then(res => res.json());
For external data you need runtime validation. A schema library such as Zod describes the shape once and checks it when the data arrives:
const UserSchema = z.object({
id: z.number(),
name: z.string()
});
const user = UserSchema.parse(data);
A useful way to remember the split: the compiler protects your code, while runtime validation protects your application. The blog's guide to sharing one Zod schema across a React frontend and a Node backend shows how to apply this at both ends.
any, unknown and the burden of proof
any disables checking
With any, every one of these nonsensical operations compiles without complaint:
let value: any = "hello";
value.foo.bar.baz();
value();
value.notARealProperty;
any effectively tells the compiler to trust you and stop looking. That is why a parameter typed like this quietly throws away most of TypeScript's value:
function processUser(user: any) {
console.log(user.name);
}
unknown demands evidence
unknown accepts any value too:
let value: unknown = "hello";
But using it directly fails:
value.foo;
You have to narrow it first, for example to a string:
if (typeof value === "string") {
console.log(value.toUpperCase());
}
or to a number:
if (typeof value === "number") {
console.log(value.toFixed(2));
}
The difference in attitude is easy to summarize:
any
↓
"Trust me"
unknown
↓
"Prove it first"
So when you genuinely do not know a value's type, reach for
unknown
rather than
any
and let the compiler force you to prove what you have before you use it.
Compatibility is about shape, not names
Developers coming from Java, C# or C++ are often surprised that this assignment is allowed:
interface User {
name: string;
}
const employee = {
name: "Lakhveer",
salary: 100000
};
const user: User = employee;
TypeScript uses structural typing: compatibility depends on what properties a value has, not on what it was declared as. User needs only this:
name: string
and employee has it, plus more. The reasoning the compiler applies looks like this:
User requires:
name: string
employee has:
name: string
salary: number
Therefore:
employee satisfies User
or, as a decision flow:
Required properties
↓
Does object contain them?
↓
Yes
↓
Compatible
Excess property checks apply to fresh literals
Now the twist. Writing the extra field directly in an object literal is rejected:
interface User {
name: string;
}
const user: User = {
name: "Lakhveer",
salary: 100000
};
with an error like:
Object literal may only specify known properties
Yet assigning the same data through an intermediate variable passes:
const employee = {
name: "Lakhveer",
salary: 100000
};
const user: User = employee;
The reason is that TypeScript runs excess property checking on fresh object literals, the ones written directly at the point of assignment, as a guard against typos. That check is separate from structural compatibility. So "TypeScript rejects extra properties" is only true for literals; once an object has passed through a variable, extra fields are fine.
Literal types and derived unions
Exact values instead of broad types
A variable can be restricted to specific values:
let direction: "left" | "right";
direction = "left";
so this assignment fails:
direction = "up";
The declaration does not say
direction: string
It says
direction must be EXACTLY:
"left"
OR
"right"
That precision improves APIs considerably. A deployment function can accept only known environments:
type Environment =
| "development"
| "staging"
| "production";
function deploy(env: Environment) {
// ...
}
and a typo becomes a compile error rather than a failed deploy:
deploy("testing");
as const changes what gets inferred
An ordinary array literal of strings:
const colors = ["red", "blue", "green"];
is inferred as
string[]
Adding as const:
const colors = ["red", "blue", "green"] as const;
produces a read-only tuple of literal types instead:
readonly ["red", "blue", "green"]
From that tuple you can derive a union by indexing with number:
type Color = typeof colors[number];
which yields:
type Color = "red" | "blue" | "green";
This removes a common duplication. Without it, you maintain a union and an array that must be kept in sync by hand:
type Color = "red" | "blue" | "green";
const colors: Color[] = [
"red",
"blue",
"green"
];
With it, the array becomes the single source of truth and the type follows automatically:
const colors = [
"red",
"blue",
"green"
] as const;
type Color = typeof colors[number];
The principle generalizes: when the type system can derive information, do not write it twice.
Keywords that work at the type level
typeof has two jobs
In JavaScript,
typeof value
is a runtime operator. For example,
typeof "hello";
evaluates to
"string"
In a type position, TypeScript reuses the keyword to capture the static type of a variable:
const user = {
id: 1,
name: "Lakhveer"
};
type User = typeof user;
Here
User
becomes
{
id: number;
name: string;
}
Same keyword, two contexts:
Runtime:
typeof value
Type system:
typeof variable
keyof turns keys into a union
Given an interface,
interface User {
id: number;
name: string;
email: string;
}
this type
type UserKeys = keyof User;
is
"id" | "name" | "email"
Combined with generics, that lets you write a property accessor that only accepts real keys:
function getValue<T, K extends keyof T>(
object: T,
key: K
) {
return object[key];
}
Calling it with an existing key works:
const user = {
id: 1,
name: "Lakhveer"
};
getValue(user, "name");
while a missing key is rejected at compile time:
getValue(user, "salary");
The return type is also precise: T[K] resolves to the type of that particular property.
Generics connect values to each other
The textbook generic simply returns what it receives:
function identity<T>(value: T): T {
return value;
}
Generics become more interesting when they tie several values together. Here both arguments must share one type:
function pair<T>(first: T, second: T): [T, T] {
return [first, second];
}
so this call is accepted:
pair(10, 20);
but this one fails, because T is inferred from the first argument as number and a string is not assignable to it:
pair(10, "hello");
Generics can also connect input to output honestly, including the empty case:
function first<T>(items: T[]): T | undefined {
return items[0];
}
For a call like
const numbers = first([1, 2, 3]);
the compiler reports the result as
number | undefined
Computing types
Conditional types are a type-level if
A conditional type chooses between two results based on a check:
type IsString<T> =
T extends string
? true
: false;
So
type A = IsString<string>;
resolves to
true
and
type B = IsString<number>;
resolves to
false
Conceptually you are writing this, except it runs in the compiler rather than in your program:
if T is string
return true
else
return false
infer extracts parts of a type
Inside a conditional type, infer introduces a type variable that TypeScript fills in by matching. This reimplements the built-in ReturnType:
type ReturnTypeOf<T> =
T extends (...args: any[]) => infer R
? R
: never;
Applied to a real function, it pulls out the returned object's type:
function getUser() {
return {
id: 1,
name: "Lakhveer"
};
}
type User = ReturnTypeOf<typeof getUser>;
The mental model is a pattern match:
Function
↓
infer R
↓
Extract return type
Many of the standard utility types are built exactly this way.
Mapped types transform every property
Starting from an interface,
interface User {
id: number;
name: string;
email: string;
}
a mapped type iterates over its keys to produce a read-only version:
type ReadonlyUser = {
readonly [K in keyof User]: User[K];
};
or an optional one:
type OptionalUser = {
[K in keyof User]?: User[K];
};
instead of rewriting each field by hand:
id?: number;
name?: string;
email?: string;
The utility types are built from these pieces
TypeScript ships a library of such helpers:
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>
Given a model like this,
interface User {
id: number;
name: string;
email: string;
}
Pick keeps chosen keys:
type UserPreview = Pick<User, "id" | "name">;
producing
{
id: number;
name: string;
}
and Omit removes them:
type UserWithoutEmail = Omit<User, "email">;
For the full set, see the blog's guide to TypeScript's built-in utility types.
never, narrowing and guards
never proves you handled everything
never is the type of a value that cannot exist, such as the result of a function that always throws:
function fail(message: string): never {
throw new Error(message);
}
Its real power shows in exhaustiveness checks. Take a status union:
type Status =
| "loading"
| "success"
| "error";
and a switch whose default branch passes the value to a function that only accepts never:
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);
}
When every case is handled, status has been narrowed to never by the time it reaches default, so the call type-checks. Now suppose someone extends the union:
type Status =
| "loading"
| "success"
| "error"
| "cancelled";
The unhandled "cancelled" reaches assertNever, it is not assignable to never, and the compiler points at every switch that needs updating.
Narrowing follows control flow
The compiler tracks how checks change what a variable can be:
function print(value: string | number) {
if (typeof value === "string") {
console.log(value.toUpperCase());
} else {
console.log(value.toFixed(2));
}
}
In the first branch,
value
is known to be
string
and in the second it is
number
Custom type guards
You can teach the compiler to recognize your own types with a function whose return type is a type predicate, value is User:
interface User {
name: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"name" in value
);
}
After the guard succeeds,
const data: unknown = getData();
if (isUser(data)) {
console.log(data.name);
}
the compiler treats the value as
data: User
inside the block. Be aware that the compiler trusts the predicate completely. This guard only checks that name exists, not that it is a string, so a sloppy guard is effectively an unchecked assertion.
Modeling state with discriminated unions
A common but weak design puts every possibility into one object with optional fields:
interface State {
status: string;
data?: User;
error?: string;
}
A discriminated union models each state separately, tagged by status:
type State =
| {
status: "loading";
}
| {
status: "success";
data: User;
}
| {
status: "error";
error: string;
};
Switching on the tag narrows each branch to exactly the fields that exist there:
function render(state: State) {
switch (state.status) {
case "loading":
return "Loading...";
case "success":
return state.data.name;
case "error":
return state.error;
}
}
This rules out contradictions such as
status = success
error = "Something went wrong"
which the loose version happily allows. The guiding principle is to model valid states rather than permitting invalid ones and checking for them everywhere.
satisfies checks without overriding
satisfies verifies that an expression conforms to a type while keeping the expression's own inferred type:
const config = {
port: 3000,
host: "localhost"
} satisfies {
port: number;
host: string;
};
That is ideal for configuration objects, where you want the check but also want literal values and exact keys preserved. Compare an assertion:
const config = {...} as Config;
as tells the compiler to treat the value as that type, and it will accept a lot on your word. satisfies asks the compiler to confirm conformance instead. When your intent is validation rather than overriding the checker, prefer
satisfies
over
as
Limits that catch people out
readonly is shallow
Consider a type with read-only properties, one of which is an object:
type User = {
readonly name: string;
readonly address: {
city: string;
};
};
Reassigning the top-level property is blocked:
user.name = "New Name";
but changing a field inside the nested object is still allowed:
user.address.city = "Indore";
readonly applies only to the property it marks, not recursively. Deep immutability needs a recursive mapped type or a runtime mechanism, and note that Object.freeze is shallow as well.
Assertions do not convert values
This double assertion compiles:
const value = "123" as unknown as number;
but nothing is converted. At runtime,
typeof value
still reports
string
If you need a number, convert explicitly:
const value = Number("123");
Assertions change what the compiler believes, never what the value is.
Optional is not always the same as undefined
An optional property:
interface User {
name?: string;
}
usually means the key may be absent, so an empty object
{}
is valid, and so is
{
name: "Lakhveer"
}
But whether an explicit
{
name: undefined
}
is allowed depends on configuration. Enabling
{
"exactOptionalPropertyTypes": true
}
makes the compiler distinguish the two. This matters for APIs where
property missing
and
property explicitly undefined
mean different things, for example in a PATCH request where a missing field means "leave unchanged" and an explicit value means "clear it".
Indexing can hide undefined
TypeScript deliberately does not try to prevent every runtime error. Reading past the end of an array:
const numbers = [1, 2, 3];
const value = numbers[100];
is, under default settings, typed as if a number is always there. Turning on
{
"noUncheckedIndexedAccess": true
}
makes the access
numbers[100]
report as
number | undefined
which pushes you to handle the missing case.
String and relational types
Template literal types
TypeScript can build string types from other string types:
type EventName =
`user:${"created" | "updated" | "deleted"}`;
which expands to
"user:created"
"user:updated"
"user:deleted"
The same technique can describe routes:
type HttpMethod = "GET" | "POST";
type Endpoint =
`${HttpMethod} /users`;
so the valid values are
"GET /users"
"POST /users"
Combining the pieces into computed APIs
These features compose:
keyof
typeof
conditional types
mapped types
template literals
infer
generics
Start with a map from event names to payload types:
type EventMap = {
userCreated: {
id: number;
};
userDeleted: {
id: number;
};
};
A generic emitter can then tie each event name to its payload using keyof and indexed access:
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]
) {
// ...
}
}
Emitting a known event with the right payload compiles:
const emitter =
new EventEmitter<EventMap>();
emitter.emit("userCreated", {
id: 1
});
while the wrong payload is rejected:
emitter.emit("userCreated", {
name: "Lakhveer"
});
The compiler now understands the relationship between an event name and the data that must accompany it.
Start strict
A professional project should generally begin with:
{
"compilerOptions": {
"strict": true
}
}
That single flag enables a family of checks, including:
strictNullChecks
noImplicitAny
strictFunctionTypes
strictPropertyInitialization
useUnknownInCatchVariables
It also turns on options such as strictBindCallApply and noImplicitThis. Adding safety only after bugs appear is far more expensive than letting the compiler act as the first line of defense.
Make invalid states unrepresentable
Consider a request lifecycle modeled as a union:
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: string };
Compare it to a flag-and-optional-fields design:
interface RequestState {
loading: boolean;
data?: User;
error?: string;
}
The second allows nonsense such as
{
loading: true,
data: user,
error: "Something failed"
}
while the first makes those combinations impossible to construct. If there is one design principle to take from TypeScript, it is this one. For a deeper treatment, see modeling domains in TypeScript beyond basic annotations.
Design principles for production TypeScript
Knowing the features is not the same as designing well with them. The following principles are about how you shape systems.
Treat any as a last resort
Instead of
function process(data: any) {
// ...
}
prefer
function process(data: unknown) {
// validate/narrow first
}
and, when you know the shape, better still
function process(data: User) {
// ...
}
Use any only when you understand exactly what you are giving up.
Let inference handle the obvious
Annotations like these add noise:
const name: string = "Lakhveer";
const age: number = 28;
The compiler already knows:
const name = "Lakhveer";
const age = 28;
Save explicit types for places where they document a contract.
Let types carry intent
A bare string says little:
function process(value: string) {}
A named type says what the value means:
type UserId = string;
function processUser(userId: UserId) {}
One caveat: an alias like UserId = string documents intent but does not stop you passing a ProductId where a UserId is expected, since both are just strings. If mixing them up is a real risk, a branded type adds that enforcement.
Keep types close to the domain
A signature built from raw strings:
function createOrder(
userId: string,
productId: string,
status: string
) {}
becomes much clearer with domain types:
type OrderStatus =
| "pending"
| "paid"
| "cancelled";
function createOrder(
userId: UserId,
productId: ProductId,
status: OrderStatus
) {}
Now the compiler understands your business vocabulary, not just primitive shapes.
Prefer unions over boolean flags
Independent booleans allow impossible combinations:
interface State {
loading: boolean;
success: boolean;
error: boolean;
}
A union allows exactly one state at a time:
type State =
| "loading"
| "success"
| "error";
Move to a discriminated union when a state needs its own data.
Validate at system boundaries
The compiler cannot vouch for anything that enters from outside:
API
Database
User input
Environment variables
Files
Third-party services
JSON
Local storage
Treat all of these as untrusted and route them through a single pipeline:
External data
↓
Runtime validation
↓
Trusted typed data
↓
Application logic
Validated data becomes trusted typed data, and only that reaches application logic.
Resist overengineering
You can write extremely intricate types, but a signature like
type Something<T, U, V, X extends ...> = ...
that nobody on the team can explain six months later is technical debt. Types should make the codebase clearer, not demonstrate cleverness.
Design APIs for correct use
A call with several positional flags is easy to get wrong:
createUser(
"Lakhveer",
"admin",
true,
false,
undefined
);
A typed options object is self-describing and gives far better autocomplete:
createUser({
name: "Lakhveer",
role: "admin",
active: true
});
Compose instead of building giant interfaces
A single interface with dozens of fields
interface User {
// 50 properties
}
is harder to reason about than smaller concepts combined with intersections:
type Identifiable = {
id: string;
};
type Timestamped = {
createdAt: Date;
updatedAt: Date;
};
type User =
Identifiable &
Timestamped & {
name: string;
};
Make the compiler part of your testing strategy
Types do not replace tests, but they remove whole categories of bugs before tests run. Given
type PaymentStatus =
| "pending"
| "paid"
| "failed";
adding a new member such as
"refunded"
will, combined with exhaustiveness checks, reveal every place that forgot to handle it.
Keep compile time and runtime separate in your head
Ask which layer you are working in. This is compile time only:
interface User {
id: number;
}
This is a runtime check:
if (typeof value === "object") {
}
And this is runtime validation of external data:
UserSchema.parse(data);
Read the emitted JavaScript
When behavior is confusing, ask what JavaScript the code turns into. Knowing both layers explains most surprises.
Know JavaScript deeply
TypeScript sits on top of JavaScript, so fundamentals still matter:
Closures
Promises
Event Loop
Prototypes
this
Modules
Destructuring
Async/Await
Objects
Arrays
Functions
Hoisting
Scopes
Keep tsconfig.json deliberate
Do not copy a configuration blindly. Understand what each option buys and costs:
{
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true
}
Every flag shifts the balance between safety and convenience; noImplicitOverride, for instance, requires override on any method that replaces one from a base class.
A layered mental model
It helps to picture TypeScript as two parallel layers, runtime JavaScript and a compile-time type system, with the type-level features building on each other:
TypeScript
│
┌────────────┴────────────┐
│ │
JavaScript Type System
│ │
Runtime Behavior Compile-Time Safety
│ │
Browser / Node Type Relationships
│
┌──────────┼──────────┐
│ │ │
Generics Unions Inference
│ │ │
keyof never conditional
│ │ │
mapped guards infer
│ │ │
└──────────┴──────────┘
Seen this way, TypeScript stops looking like a pile of syntax rules and becomes a language for describing relationships between values: which values are allowed, how objects relate, which states can occur, what functions accept and return, and which cases are still unhandled.
Key takeaways
The features most worth mastering are not the flashiest ones:
Generics
Unions
Narrowing
Inference
keyof
typeof
Mapped Types
Conditional Types
infer
Discriminated Unions
never
unknown
satisfies
Template Literal Types
- Types are erased at runtime, so external data always needs validation.
- Compatibility is structural, with an extra check only on fresh object literals.
as const,typeof,keyof, conditional and mapped types let you derive types instead of duplicating them.- Prefer
unknowntoanyandsatisfiestoaswhen your goal is checking, not overriding. - Use discriminated unions and
neverso the compiler can tell you whether a state can actually happen.
The aim is not the most sophisticated types, but code where the compiler answers "can this state occur?" before the program ever runs. Use TypeScript to design safer code, not merely to describe the code you already wrote.