My TypeScript ‘Aha!’ Moments: Best Practices I Learned on a Real Project
I just spent a semester wrestling with a pretty complex web application for my software engineering course. We chose TypeScript from the start, thinking it would magically prevent all bugs. It didn’t. But what it did do was force us to be better engineers. Along the way, I had a few “aha!” moments—things that, once they clicked, completely changed how I write code. Here are the big ones.
Lesson 1: Your tsconfig.json
is Your First Line of Defense
When you first initialize a TypeScript project, you get a tsconfig.json
file. My initial instinct was to ignore it. Big mistake. The single most important thing I learned was to enable strict: true
.
Turning on strict mode is like activating a whole suite of safety checks. It forces you to handle null
and undefined
values, ensures your functions have explicit return types, and stops you from using any
implicitly. It felt annoying at first, like the compiler was constantly yelling at me. But every error it caught was a potential runtime bug that would have been a nightmare to track down later. Don’t skip this. It’s your best friend.
Lesson 2: any
is a Lie
When you’re in a hurry, it’s so tempting to type something as any
. It makes the compiler errors go away, and you can move on. I learned the hard way that any
is a lie. It’s a backdoor that lets you escape the type system, which defeats the entire purpose of using TypeScript.
Every time we used any
, it came back to bite us. We’d pass the wrong type of data, and the error would show up deep in some other part of the application, far from the source of the problem. The rule we eventually adopted was: if you don’t know the type, use unknown
. unknown
is the type-safe version of any
. It forces you to do a type check before you can use the variable, which is exactly what you should be doing.
Lesson 3: Use interface
for Shapes, type
for Everything Else
The interface
vs. type
debate is endless online. Our team settled on a simple convention that made our code much easier to read:
- Use
interface
when defining the “shape” of an object. Think of it as a contract for what aUser
or aProduct
object should look like. Interfaces can also be extended, which is great for object-oriented patterns. - Use
type
for everything else. This includes creating union types (type Status = 'pending' | 'success'
), defining function signatures, or creating complex types using utility types.
This simple rule brought a lot of clarity to our codebase.
Lesson 4: Utility Types Are Pure Magic
This was my biggest “aha!” moment. I spent hours writing boilerplate types until a TA showed me TypeScript’s built-in utility types. They are incredible time-savers.
- Need to create a new type for a form that has all the properties of your
User
interface, but they should all be optional? That’s justPartial<User>
. - Need a type that only has the
id
andname
from yourUser
? That’sPick<User, 'id' | 'name'>
. - Need a type that has everything except the
password
?Omit<User, 'password'>
.
Learning to use these utility types felt like unlocking a superpower. It made our code more concise and much more maintainable.
Lesson 5: Discriminated Unions for State Management
In our React components, we used to have a bunch of boolean flags to manage state: isLoading
, isError
, isSuccess
. It was a mess.
The pattern that cleaned this up completely was discriminated unions. We created a state type like this:
1 | type ComponentState<T> = |
The status
property is the “discriminant.” In our component, we could now use a simple switch
statement on state.status
. The best part is that TypeScript is smart enough to know that if state.status
is 'success'
, then state.data
must exist. It made our rendering logic clean, safe, and impossible to mess up.
This project taught me that TypeScript isn’t about adding rules for the sake of it. It’s about providing a framework for writing code that is easier to reason about, maintain, and refactor. These practices were my lifelines, and I won’t be starting another project without them.