Where TypeScript stops being enough
TypeScript has made many parts of frontend and backend work easier for me to reason about. A clear type can explain a function’s input, make a refactor safer, and help the next person understand what a piece of code expects.
At the same time, I have learned not to confuse a type with a guarantee. TypeScript helps while code is being written and compiled. The product still runs as JavaScript, where data can arrive in shapes the type system never had a chance to check.
Types make promises inside the codebase
Within a codebase, types make assumptions visible. A function can say what it accepts and returns. A component can show which props it needs. A service can describe the data it expects from another part of the application.
That clarity has been especially useful when changing existing code. If a field is renamed or a value becomes optional, TypeScript can point to the places that need another look. It does not remove the need to think the change through, but it keeps fewer assumptions hidden.
For me, this is one of the best parts of TypeScript: it turns some of the knowledge that usually lives in a developer’s head into something the editor can help check.
The outside world does not know our types
The picture changes when data comes from outside the codebase. An HTTP request body, a URL query, an environment variable, a local storage value, an uploaded file, or a response from another service does not know about a TypeScript interface.
It may be missing a field, contain a value of the wrong type, or follow an older version of a contract. Casting that value with as SomeType may keep the compiler quiet, but it does not change what arrived at runtime.
This has been a useful reminder for me: external data starts as unknown. It earns a more specific type only after the application has checked or transformed it.
unknown and becomes a typed value only after it passes the boundary.Validate at the boundary
Runtime validation is most useful near the place where data enters the system. A backend can validate and transform a request before business logic runs. A frontend can validate form input before submitting it. A configuration loader can check environment variables once, rather than letting an undefined value fail somewhere later.
The same idea helps with API clients. Instead of assuming every response matches the latest frontend type, the client can parse the part of the response it depends on and handle an unexpected shape deliberately. A schema library such as Zod keeps the check and the type in one place:
// The compiler is quiet, but nothing was checked
const unchecked = (await res.json()) as User;
// Check at the boundary, then let the type follow
const UserSchema = z.object({ id: z.string(), name: z.string() });
type User = z.infer<typeof UserSchema>;
const result = UserSchema.safeParse(await res.json());
if (!result.success) {
// handle the unexpected shape here, not three screens later
}This does not mean validating every value again and again throughout the application. The point is to establish a trustworthy boundary, then let the code behind it work with clearer assumptions.
Types and validation solve different problems
Types are still valuable after runtime validation exists. They make code easier to read, improve autocomplete, and help refactors stay honest. Validation protects the product from data that does not follow the contract when it actually runs.
I have found the combination more useful than relying on either one alone. A type without validation can describe an assumption that turns out to be false. Validation without clear types can keep the product safe while making the code harder to work with.
The goal is not to make every line defensive. It is to know where the application stops controlling the data, then make that handoff explicit enough for the next change to be safer.
Working on something similar? Let's talk