I write Go most of the time. When I work on TypeScript projects – which happens regularly – I appreciate what it brings to the JavaScript ecosystem. I also think the community overcomplicates it. Generics four levels deep, utility type gymnastics, type-level programming that requires a PhD to review. The language is at its best when the types are simple and the boundaries are strict.
Here is what I think actually matters for large TypeScript codebases, from someone who would rather be writing Go.
Turn on strict mode. Keep it on.
{
"compilerOptions": {
"strict": true
}
}
This is non-negotiable. strict: true catches null errors, implicit any types, and a whole class of runtime surprises. If your project doesn’t have this enabled, every type annotation is half a lie because the compiler isn’t actually enforcing the contract.
Retrofitting strict mode into an existing codebase is painful. Do it early or pay for it later. There’s no third option.
Validate at the boundaries, trust the interior
The biggest insight from Go that translates directly to TypeScript: treat external data as hostile. API responses, form inputs, queue messages, file contents – all of it’s unknown until you validate it.
function parseUser(data: unknown): User {
if (typeof data !== "object" || data === null) {
throw new Error("expected object");
}
if (!("id" in data) || !("email" in data)) {
throw new Error("missing required fields");
}
return data as User;
}
Use a validation library like Zod if you want something less manual. The point is: validate once at the edge, then keep the interior of your codebase strongly typed. No any leaking through from API calls. No as unknown as Whatever casts to paper over the fact that you don’t know what shape the data is.
Discriminated unions are the best feature
This is the one thing TypeScript does better than Go. (I said what I said.)
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
Pattern match on the discriminant. The compiler enforces exhaustiveness. No forgotten error branches. No if err != nil – although honestly, I’ve come to appreciate Go’s explicitness there too.
Use discriminated unions for state machines, API responses, and anything with multiple possible shapes. They’re readable, the tooling support is excellent, and they prevent an entire category of bugs.
Stop overusing generics
This is where TypeScript culture drives me up a wall. I regularly see utility types like DeepPartial<Omit<Pick<T, K>, "id"> & Required<Whatever>> in production code. Nobody can read that. Nobody can review it confidently. It’s type-level cleverness for its own sake.
In Go, the philosophy is: if you can’t explain it in one sentence, it’s too complicated. The same should apply to TypeScript generics. Constrain your type parameters. Keep generic functions to one or two type parameters. If a type definition needs a comment to explain what it does, simplify it.
Keep modules clean
Cyclic dependencies are the slow death of large TypeScript projects. Use type-only imports to make intent clear:
import { createUser } from "./user.js";
import type { User } from "./user.js";
Export types and values from the same module when they belong together. Don’t create a types.ts file that every module imports from – that’s a dependency magnet.
The honest assessment
TypeScript is a significant improvement over JavaScript for anything beyond a small script. The compiler catches real bugs. Discriminated unions and strict null checks prevent real production incidents. The tooling is genuinely good.
But it isn’t a type system that enforces correctness the way Go or Rust does. It’s a type system bolted onto a dynamic language, with escape hatches everywhere. any, type assertions, @ts-ignore – the temptation to bypass the system is always one keystroke away.
The discipline isn’t in the language. It’s in the team. Strict mode on, any banned in lint rules, boundaries validated, generics kept simple. Do that and TypeScript earns its complexity budget. Skip it and you get all the overhead of a type system with half the guarantees.