TypeScript brought long-needed structure to JavaScript, but it also brought a misunderstanding: the idea that it turns JavaScript into a statically typed, object-oriented language. It doesn't, and systems designed as if it did are fragile, because they target a runtime model that isn't the one executing the code.
TypeScript works best as a descriptive layer on top of JavaScript's real behavior, not as a way to simulate Java. That requires understanding how JavaScript actually models objects, functions, structure, and data. Used that way, it documents intent that plain JavaScript leaves implicit. It does not enforce correctness.
What follows is a deeper look at the real architecture of JavaScript systems: why OOP breaks down, why composition works, how factories outperform classes, how runtime validation completes the picture, and how TypeScript can support all of this if used with awareness rather than assumptions.
JavaScript's runtime rules everything#
Every TypeScript project ultimately compiles to JavaScript, and JavaScript is the only thing the runtime knows. TypeScript's correctness doesn't exist at runtime: its types aren't carried forward, its checks aren't enforced, and its interfaces evaporate entirely.
This means your architecture must make sense in JavaScript, not in TypeScript's imagined static world. The compiler can't save you from API drift, malformed data, wrong assumptions, or incorrect usage.
A typical case: a payment integration with a well-typed model of the provider's webhook payload. The provider changes the payload structure. The code still compiles and fails in production, because the incoming data was never validated. The types described a contract the provider never agreed to.
JavaScript is a dynamic language with functional roots and prototype-based objects, built around late binding and function-centric composition. TypeScript is more effective when the architecture follows that model.
Why JavaScript was never an OOP language#
JavaScript's class keyword looks like something from Java or C#, so a lot of people unconsciously import that mental model. They start thinking in terms of hierarchies, base classes, casts, and runtime type identity, none of which exist in JavaScript.
Under the hood, a class is just a function with a prototype. Instances don't carry a strong notion of "this is a User" in the way Java objects do. The runtime only cares about the properties that happen to be present.
Take this:
class User {
constructor(public name: string, public age: number) {}
}
const u = new User("Ada", 34);At runtime this is just an object with name and age hanging off a prototype. There is nothing magical or enforced about it being a User.
In Java:
User u = (User) something;that cast is part of the runtime semantics. If something is not a User, the VM throws. The type hierarchy is real; it's enforced while the program runs.
Now compare that with TypeScript:
const u = something as User;This is not a cast. It isn't checked at runtime and can't fail; it's a compile-time hint that is removed from the emitted JavaScript. If something has the wrong shape, the runtime won't say anything until you try to use a missing property. It's the TypeScript version of "trust me bro."
This is why deep inheritance hierarchies are brittle in TypeScript: the type system models the hierarchy, but the runtime doesn't enforce any of it.
Objects in JavaScript work well as data containers with some attached behavior. They don't work well as the backbone of a class hierarchy.
There are exceptions. NestJS, for example, is built on classes and decorators, and working against it costs more than it saves. Subclassing Error is one of the few places where the prototype chain actually matters. In those cases, keep the classes thin and put the logic in plain functions.
Composition works because it matches JavaScript's nature#
Composition doesn't depend on a runtime hierarchy. It combines small objects and functions into larger ones, which is what JavaScript is good at.
A creature that can fly and shoot lasers, built from plain objects and functions:
const canFly = (state: { velocity: number }) => ({
fly: () => {
state.velocity++;
}
});
const hasLaserEyes = (state: { energy: number }) => ({
shoot: () => {
state.energy -= 10;
}
});
type CreatureState = {
velocity: number;
energy: number;
};
const createCreature = (state: CreatureState) => ({
getState: () => ({ ...state }),
...canFly(state),
...hasLaserEyes(state)
});There is no base class, no casting and no abstract supertype. The object's behavior is the set of pieces spread into it.
TypeScript handles this pattern well, because the types are just a direct description of what the runtime is already doing.
In general, the closer the architecture is to how JavaScript behaves, the less work the type system has to do.
The inheritance trap#
A typical inheritance-based repository:
abstract class BaseRepository<T> {
constructor(protected tableName: string) {}
protected abstract validate(data: unknown): T;
async findById(id: string): Promise<T | null> {
const row = await db.query(
`SELECT * FROM ${this.tableName} WHERE id = ?`,
[id]
);
return row ? this.validate(row) : null;
}
}
class UserRepository extends BaseRepository<User> {
constructor() {
super('users');
}
protected validate(data: unknown): User {
return UserSchema.parse(data);
}
}This compiles and looks reasonable.
Then requirements change. You need to add caching to some repositories but not others. You need to switch one table to a different database that doesn't support the same query format. You need to add audit logging, but only for certain models. You realize validate should return T | null for some repositories but throw for others.
Each requirement gets handled by adding flags, overriding methods, or checking instanceof to special-case behavior, and the base class accumulates all of it.
The composed version:
type Repository<T> = {
findById: (id: string) => Promise<T | null>;
create: (data: T) => Promise<T>;
};
const createRepository = <T>(config: {
tableName: string;
validate: (data: unknown) => T;
db: Database;
}): Repository<T> => ({
findById: async (id) => {
const row = await config.db.query(
`SELECT * FROM ${config.tableName} WHERE id = ?`,
[id]
);
return row ? config.validate(row) : null;
},
create: async (data) => {
await config.db.insert(config.tableName, data);
return data;
}
});
const userRepo = createRepository({
tableName: 'users',
validate: (data) => UserSchema.parse(data),
db: mainDatabase
});Caching is a wrapper:
const withCache = <T>(
repo: Repository<T>,
cache: Cache<T>
): Repository<T> => ({
findById: async (id) => {
const cached = await cache.get(id);
if (cached) return cached;
const result = await repo.findById(id);
if (result) await cache.set(id, result);
return result;
},
create: repo.create
});
const cachedUserRepo = withCache(userRepo, redisCache);A different database means a different db. A different validation strategy means a different function. Audit logging is another wrapper. Each concern is isolated and can be tested on its own.
Factory functions beat classes because they tell the truth#
Factory functions are a more direct way to create objects in JavaScript. They avoid constructors and prototypes, and they don't imply guarantees the runtime can't provide.
A clear factory looks like this:
interface WidgetParams {
id: string;
label: string;
}
interface Widget {
id: string;
label: string;
}
const createWidget = (params: WidgetParams): Widget => ({ ...params });The return type is explicit, and the object is exactly what the function returns.
Adding behavior is just as direct:
interface StatefulWidget extends Widget {
focus: () => void;
}
const createStatefulWidget = (params: WidgetParams): StatefulWidget => {
const base = createWidget(params);
return {
...base,
focus: () => {
console.log(`Focused ${base.id}`);
}
};
};TypeScript types this without help, because it's only objects and functions.
Factories also avoid the problems with this. In JavaScript, this is dynamically bound based on how a function is called, not where it's defined. A common failure:
class Counter {
count = 0;
increment() {
this.count++;
}
}
const counter = new Counter();
const handler = counter.increment;
handler(); // TypeError: Cannot read properties of undefined (reading 'count')Once the method is detached from the instance, this is lost. Developers work around this with .bind(), arrow functions in constructors, or class fields.
The factory equivalent:
const createCounter = () => {
let count = 0;
return {
increment: () => {
count++;
},
getCount: () => count
};
};
const counter = createCounter();
const handler = counter.increment;
handler(); // worksThe closure captures count, and there is no this to lose.
In class-heavy codebases, this removes a lot of accidental complexity.
Casting vs validation: making types real#
Every TypeScript developer has seen this pattern:
const user = payload as unknown as User;This tells the compiler to stop checking. At runtime it is equivalent to:
const user = payload;Nothing is checked. The only effect is that everyone reading the code will assume user really is a User.
This is a common source of bugs. API responses get treated as verified, configuration files and environment variables as if the compiler had seen them, and data crossing module boundaries as if its shape were guaranteed.
TypeScript sees none of this data. Only runtime validation checks it.
A schema library like Zod lets you define what your program considers valid, and then check it at runtime:
const UserSchema = z.object({
id: z.string(),
name: z.string().min(1),
age: z.number().int().positive()
});
type User = z.infer<typeof UserSchema>;The schema itself is only a description. What matters is where it's applied.
All untrusted data should be validated at the boundary where it first enters your code: HTTP handlers, message consumers, file readers, queue workers, cron jobs. That boundary is the only place where you can honestly say "from this point on, a User is actually a User."
At the HTTP boundary, validation failures should produce clear, specific errors:
app.post('/users', async (req, res) => {
const result = UserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Invalid user data',
details: z.flattenError(result.error)
});
}
const user: User = result.data;
// Now you can trust it
});For internal boundaries where failures represent programming errors rather than bad input, fail fast:
const processUser = (data: unknown): User => UserSchema.parse(data); // throws if invalidMalformed data from an internal module then fails immediately during development.
The same principle applies inside your own system. If data crosses a boundary you don't fully control (a different module, a plugin, a layer that might evolve on its own), treat it as suspect again. TypeScript always allows as unknown as Something. There is no guarantee that the object you passed out is the object you get back, even when the type looks identical.
Validation at the edges and at key internal boundaries is what makes the types accurate.
Type guards: still just hints#
TypeScript's type guards look safer than raw as casts, but they are not runtime validation.
const isUser = (obj: unknown): obj is User =>
typeof obj === 'object' &&
obj !== null &&
'name' in obj &&
'age' in obj;
if (isUser(data)) {
// TypeScript believes data is a User
console.log(data.name);
}This narrows the type, but it only checks that the value is a non-null object with two properties. It doesn't verify that name is a string. It doesn't check that age is a positive integer.
If data is { name: null, age: "thirty" }, the guard passes and the code fails later.
Type guards are useful for internal boundaries where you control the data flow and just need to help the type checker understand what you already know. For external data, they're not enough:
// Hand-written guard: longer, and still shallow
const isUser = (obj: unknown): obj is User =>
typeof obj === 'object' &&
obj !== null &&
'id' in obj && typeof obj.id === 'string' &&
'name' in obj && typeof obj.name === 'string' &&
'age' in obj && typeof obj.age === 'number' &&
Number.isInteger(obj.age) && obj.age > 0;
// Schema
const UserSchema = z.object({
id: z.string(),
name: z.string().min(1),
age: z.number().int().positive()
});The schema version is clearer, more thorough, and produces better error messages. It also validates deeply (nested objects, arrays, transformations) without you writing hundreds of lines of manual checks.
Use type guards when you're refining types the compiler can't infer on its own. Use schemas when you're validating data that came from outside your control.
Discriminated unions: when TypeScript actually helps#
Discriminated unions with exhaustive checking are where TypeScript's static analysis is most useful.
type Result<T, E> =
| { success: true; data: T }
| { success: false; error: E };
const unwrap = <T, E>(result: Result<T, E>): T => {
if (result.success) {
return result.data; // TypeScript knows data exists
}
throw result.error; // TypeScript knows error exists
};This works because the pattern aligns with JavaScript's actual behavior. A Result is just an object with different shapes based on a discriminant property. TypeScript tracks that property through control flow and narrows the type accordingly.
The same applies to state machines:
type RequestState =
| { status: 'idle' }
| { status: 'loading'; startedAt: number }
| { status: 'success'; data: User; completedAt: number }
| { status: 'error'; error: string; failedAt: number };
const renderRequest = (state: RequestState): string => {
switch (state.status) {
case 'idle':
return 'Not started';
case 'loading':
return `Loading since ${state.startedAt}`;
case 'success':
return `User: ${state.data.name}`;
case 'error':
return `Failed: ${state.error}`;
default: {
const unhandled: never = state;
return unhandled;
}
}
};An unhandled new state makes the never assignment fail to compile, and accessing data in the loading branch is a type error.
This works because it describes something the language already does: objects with different shapes, distinguished by a common property.
Arrow functions and the truth about values#
Functions in JavaScript are values: first-class objects that can be assigned, passed and stored.
The arrow function syntax makes this explicit:
const makeSomething = () => ({ /* ... */ });This is the same mental model as:
const something = { /* ... */ };Both are values; one is callable.
Arrow functions also eliminate the implicit binding confusion of this, since they don't have their own.
But while functional principles offer clarity, functional purity does not scale in real systems. Too many TypeScript projects try to emulate academic FP: endlessly curried functions, deeply nested compositions, point-free style, combinators wrapped around combinators. The result is code that might look elegant in a REPL but becomes opaque in a real system.
Pragmatic FP is enough: passing functions as values, small pure utilities, and composing behavior instead of inheriting it. That fits JavaScript and stays readable.
With explicit parameter and return types, the data flow is readable from the signatures alone:
const loadUser = (id: string): Promise<User | null> => { /* ... */ };
const transformUser = (user: User): PublicProfile => { /* ... */ };
const saveProfile = (profile: PublicProfile): Promise<void> => { /* ... */ };The types document each step without opening the implementations.
Testing without mocks#
Composition and factories naturally produce code that's easier to test, because the dependencies are explicit and replaceable.
Consider a typical class-based service:
class UserService {
constructor(private db: Database) {}
async create(params: { name: string; age: number }) {
const id = generateId();
return this.db.users.insert({ id, ...params });
}
}In practice, the test for it looks like this:
jest.mock('./id', () => ({ generateId: () => 'test-id' }));
const db = {
users: { insert: jest.fn(async (user) => user) }
} as unknown as Database;
const service = new UserService(db);The hidden generateId import needs module mocking. The fake database only implements the one method the test touches, so it needs as unknown as Database to compile: the same unchecked cast from earlier, now in the test suite. When UserService starts using another method on db, the test fails at runtime, not at compile time.
The factory version depends on exactly what it uses:
const createUserService = (deps: {
insert: (user: User) => Promise<User>;
generateId: () => string;
}) => ({
create: (params: { name: string; age: number }) =>
deps.insert({ id: deps.generateId(), ...params })
});The test:
const service = createUserService({
insert: async (user) => user,
generateId: () => 'test-id'
});
const result = await service.create({ name: 'Ada', age: 34 });
// { id: 'test-id', name: 'Ada', age: 34 }Both dependencies are plain functions. The fakes are fully typed, need no cast and no module mocking, and the compiler flags them when a dependency's signature changes.
TypeScript as documentation: code that reads like language#
A common assumption is that good code is documented code. In practice, most comments compensate for code that doesn't communicate.
One of the sharpest developers I've worked with said it perfectly: "It's called a programming language, so it's meant to be read."
Many are redundant:
// returns the user by id
const getUserById = (id: string): User | null => { /* ... */ };The comment repeats the signature.
Others mark code that should have been rewritten:
// This part is complicated... don't touch
const process = (input: RawThing): ProcessedThing => { /* ... */ };That comment is an admission: I couldn't express this clearly, so I'm warning you instead.
There are legitimate reasons to comment. Explaining domain constraints that aren't visible in code. Documenting a non-obvious algorithm. Highlighting an external contract imposed from outside. But these cases are the exception, not the rule.
Compare documentation-heavy code to well-typed code:
/**
* Loads a user from the database by their unique identifier.
*
* @param id - The user's ID
* @returns A Promise that resolves to the User object if found,
* or null if no user exists with that ID
*/
function loadUser(id) {
// ...
}versus:
const loadUser = (id: string): Promise<User | null> => {
// ...
};The second version carries the same information, is checked by the compiler, and can't drift out of sync with the implementation.
This principle extends to entire module boundaries:
export interface UserService {
find: (id: string) => Promise<User | null>;
create: (params: CreateUserParams) => Promise<User>;
update: (id: string, changes: Partial<User>) => Promise<User>;
delete: (id: string) => Promise<void>;
}The interface describes what the module offers without additional prose.
TypeScript turns design decisions into declarations that are machine-checked and readable, as long as the types match runtime behavior and the names match intent.
API drift and the validation layer#
One of the most common sources of production bugs in TypeScript systems is API drift: the external service changes its contract, but your code still expects the old shape.
Consider a third-party payment API:
interface PaymentWebhook {
id: string;
amount: number;
status: 'pending' | 'completed' | 'failed';
}
app.post('/webhook', async (req, res) => {
const payment = req.body as PaymentWebhook;
await processPayment(payment);
res.sendStatus(200);
});This works fine until the payment provider adds a new status: 'refunded'. Neither the code nor the types know about it.
Now payment.status is 'refunded', a value your union says cannot exist. A switch over the three known statuses silently falls through to its default case, or worse, hits the exhaustiveness check you trusted and throws deep inside your business logic.
With validation:
const PaymentWebhookSchema = z.object({
id: z.string(),
amount: z.number(),
status: z.enum(['pending', 'completed', 'failed'])
});
type PaymentWebhook = z.infer<typeof PaymentWebhookSchema>;
app.post('/webhook', async (req, res) => {
const result = PaymentWebhookSchema.safeParse(req.body);
if (!result.success) {
await quarantine.store(req.body, result.error);
return res.sendStatus(202);
}
await processPayment(result.data);
res.sendStatus(200);
});The first time a webhook arrives with status: 'refunded', validation fails. The payload goes to quarantine, and nothing reaches your state. Once refunded is added to the schema, the quarantined events can be replayed.
The type documents the expected shape; only the schema checks it.
Bringing it all together#
A TypeScript codebase becomes durable when its structure reflects the language it ultimately runs on. JavaScript is dynamic, expressive, function-first, and deeply flexible. The systems built on top of it should embrace those traits rather than fight them.
Here's what that looks like in practice:
Validate all external data at entry points. APIs, webhooks, file uploads, message queues: if it comes from outside your process, validate it before you trust it.
Use factories instead of classes for object creation. Keep construction explicit and avoid the ceremony and confusion of prototypes and this.
Prefer composition over inheritance. Build systems from small, replaceable pieces rather than rigid hierarchies that resist change.
Use arrow functions by default. They're clearer, safer, and more honest about how JavaScript actually works.
Never cast without validation. Treat as as a code smell. If you can't validate, at least acknowledge the risk explicitly.
Make function signatures document intent. A good type signature tells the reader what the function expects and what it promises to return. That's often better than paragraphs of prose.
Keep types close to runtime reality. The further your types drift from what actually happens at runtime, the less useful they become.
Test that validation actually runs. Don't just test happy paths. Test that your schemas reject malformed data and that your error handling works.
Treat type guards as hints, not guarantees. Use them to help the compiler understand internal invariants, but don't mistake them for validation.
TypeScript will never replace the dynamic nature of JavaScript, and it shouldn't try to. Its power lies in documenting what you meant to build (the contracts, the expectations, the flow of data) while the runtime enforces the reality underneath.
If the types describe your intent, and your validation enforces the truth, everything between those two layers becomes easier to understand and maintain.
TypeScript doesn't provide safety by itself. It provides structure and a way to express how the system should behave; the safety comes from respecting the runtime, validating what enters it, and building abstractions that match how JavaScript works.
TypeScript can't save you.
But used honestly, it makes it much easier to save yourself.
