This article is published in English.
Modeling Domains in TypeScript: Beyond Basic Type Annotations
Learn practical TypeScript habits—from unknown vs any to discriminated unions and satisfies—that help you model valid states instead of just labeling data.
TypeScript is deceptively simple to pick up.
First you learn interfaces.
Then type aliases.
After that come unions, generics, utility types, and the occasional mapped type.
Before long you can glance at a plain JavaScript object and slap a type on it without hesitation.
But at some point, working with TypeScript stops being about attaching types to things.
It turns into designing types on purpose.
That's a fundamentally different skill to build.
Take a look at this example:
type Payment = {
status: 'SUCCESS' | 'FAILED'
transactionId?: string
error?: string
}
At first glance it seems fine.
But think about which states this type technically permits.
It allows all of these:
{
status: 'SUCCESS'
}
{
status: 'SUCCESS',
error: 'Something went wrong'
}
{
status: 'FAILED',
transactionId: '123'
}
{
status: 'FAILED',
error: 'Something went wrong'
}
The type has no concept of which combinations actually make sense together.
This isn't a limitation of TypeScript itself.
It's a sign that the domain was modeled poorly.
A better version looks like this:
type Payment =
| {
status: 'SUCCESS'
transactionId: string
}
| {
status: 'FAILED'
error: string
}
Now the type system encodes the actual business rule directly.
A successful payment must carry a transaction ID.
A failed payment must carry an error message.
Combinations that don't make sense become hard, or outright impossible, to construct.
This is the point where TypeScript starts getting genuinely useful.
The point isn't sprinkling type annotations everywhere you can.
It's making your types express the rules your application actually follows.
Below are several habits that push you in that direction.
1. Stop Using any When You Actually Mean "I Don't Know"
One of the fastest ways to silence a TypeScript complaint is this:
const response: any = await fetchData()
Sometimes that really is what's going on.
You hit an error.
You're mid-implementation.
You can't immediately work out the correct type.
So any goes in.
The compiler goes quiet.
But so does all the help TypeScript was giving you.
Once any sneaks into your codebase:
const response: any = await fetchData()
response.user.profile.name // not checked
response.foo.bar.baz // not checked
TypeScript has no way to catch either of these mistakes.
Use unknown when the value is genuinely unknown
const response: unknown = await fetchData()
This forces you to actually determine what the value is before using it.
if (typeof response === 'string') {
console.log(response.toUpperCase())
}
For anything more complex than a primitive, validate the shape at the boundary instead.
The distinction matters here:
unknownsays "I don't know this yet."anysays "I don't want TypeScript checking this at all."
Those are two very different intentions.
When you're handling data coming from outside your system, unknown is almost always the more truthful starting point.
2. Don't Type What TypeScript Already Knows
Writing strongly typed code doesn't mean annotating every single variable by hand.
This version:
const name: string = 'Akshat'
const age: number = 30
const active: boolean = true
isn't inherently better than this one:
const name = 'Akshat'
const age = 30
const active = true
TypeScript can already infer these types on its own.
Annotating everything just adds visual clutter without adding real information.
Explicit annotations earn their place when they convey something meaningful.
For instance:
function calculateTotal(
items: Product[],
discount: number
): number {
// ...
}
Here, the function signature effectively documents part of a contract.
That's genuinely useful information.
A helpful check to run is:
Is this annotation telling TypeScript something it couldn't already figure out on its own?
If the answer is no, you can probably drop it.
3. Use as const When Values Are Also Types
Consider an object like this:
const STATUS = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
}
Sometimes you want the individual values to stay as literal types rather than widen to string.
That's exactly what as const gives you:
const STATUS = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
} as const
From there:
type Status = typeof STATUS[keyof typeof STATUS]
resolves to:
'ACTIVE' | 'INACTIVE'
This pattern shines when you need both the runtime values and the matching compile-time type from a single definition.
For example:
export const ALTERNATE_CODE_TYPES = {
CHARGE_CODE: 'CHARGE_CODE',
NFTP_MDG_CODE: 'NFTP_MDG_CODE',
FACT_MDG_CODE: 'FACT_MDG_CODE',
CW1_CHARGE_CODE: 'CW1_CHARGE_CODE',
} as const
export type AlternateCodeType =
typeof ALTERNATE_CODE_TYPES[keyof typeof ALTERNATE_CODE_TYPES]
Here, the object itself and the derived type share one origin.
That means you avoid maintaining a separate declaration like:
type AlternateCodeType =
| 'CHARGE_CODE'
| 'NFTP_MDG_CODE'
| 'FACT_MDG_CODE'
| 'CW1_CHARGE_CODE'
on top of it.
Keeping a single source of truth is far easier to manage than syncing two definitions by hand.
4. Use Union Types When the Domain Has a Fixed Set of States
When a value can only take on a handful of possibilities, your types should say so directly.
Rather than writing:
function setStatus(status: string) {
// ...
}
prefer:
type Status = 'pending' | 'approved' | 'rejected'
function setStatus(status: Status) {
// ...
}
With this in place, the following call works fine:
setStatus('approved')
but this one gets rejected:
setStatus('something-else')
The tighter you make a type, the more work the compiler can do for you.
This benefit goes well beyond editor autocomplete. A precise union also helps with:
- refactoring
- documentation
- error detection
- API design
- discoverability
If your business logic genuinely only allows three possible values, don't represent that field as a loose string.
5. Don't Reach for Enums Automatically
Enums are sometimes the right tool, but they shouldn't be your default choice for every group of constants.
If all you need is a compile-time union, something like:
type Status = 'ACTIVE' | 'INACTIVE'
is often sufficient.
If you also need those values to exist at runtime, use:
const STATUS = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
} as const
type Status = typeof STATUS[keyof typeof STATUS]
This gives you both a type and a real object to work with.
The key detail to internalize is that TypeScript types vanish once your code runs. A plain object doesn't.
So the question to ask is:
Does this value need to exist at runtime, or is it only there to constrain things at compile time?
Pick the approach that matches the answer.
6. Make Invalid States Impossible to Represent
This might be the single most valuable idea in this whole discussion.
Imagine a form component that can be in one of these states:
- loading
- ready
- submitting
- successful
- failed
A typical, but flawed, way to model this is:
type FormState = {
loading: boolean
submitting: boolean
error?: string
data?: FormData
}
With this shape, nothing stops you from accidentally producing something like:
{
loading: true,
submitting: true,
data: {...},
error: 'Something went wrong'
}
What does that combination actually represent? The type system has no idea, and neither will the next developer reading it.
A better structure ties the fields together based on state:
type FormState =
| { status: 'loading' }
| { status: 'ready'; data: FormData }
| { status: 'submitting'; data: FormData }
| { status: 'success'; data: FormData }
| { status: 'error'; error: string }
Now every branch carries exactly the data that makes sense for it.
function render(state: FormState) {
switch (state.status) {
case 'loading':
return 'Loading...'
case 'ready':
return state.data
case 'submitting':
return 'Submitting...'
case 'success':
return state.data
case 'error':
return state.error
}
}
That's the benefit discriminated unions give you.
Rather than modeling an application as a loose bag of independent booleans and optional fields, you model it as a fixed set of legitimate states. That's a far more reliable foundation.
7. Be Careful With Optional Properties
Optional fields have their uses, but they're also an easy way to smuggle in uncertainty without noticing.
Take this example:
type User = {
id?: string
name?: string
email?: string
}
With this definition, every piece of code consuming a User now has to handle the case where none of these fields are present.
But maybe the real rule in the domain is:
A User always has an ID, a name, and an email address.
If that's true, model it that way:
type User = {
id: string
name: string
email: string
}
Optional properties should reflect fields that are genuinely sometimes absent. They aren't meant to stand in for a vague acknowledgment that whoever wrote the type wasn't sure what the API would actually send back.
If the uncertainty originates from some external system, handle it right at that boundary. Don't let uncertainty from one integration spread through the whole codebase.
8. Understand null vs undefined
In practice, this difference ends up mattering more than people expect.
Take this type:
type User = {
middleName: string | null
}
This phrasing suggests:
The field is present, but there's deliberately no value for it.
Now compare it with:
type User = {
middleName?: string
}
which typically implies:
The field might not be there at all.
The distinction becomes especially relevant around APIs. In a PATCH request, this body:
{
middleName: null
}
could mean:
Remove the existing middle name.
whereas this body:
{}
could mean:
Leave the middle name untouched.
If the types can't express that difference, subtle bugs can creep in right at the API layer.
Remember that types exist to communicate meaning, not just to satisfy the compiler.
9. Use satisfies Instead of Blindly Asserting Types
Consider a config type like:
type Config = {
timeout: number
retries: number
}
One option is to write:
const config = {
timeout: 5000,
retries: 3,
} as Config
But as is an assertion, and using it basically tells the compiler to accept the value without question.
A generally better approach is:
const config = {
timeout: 5000,
retries: 3,
} satisfies Config
With this version, TypeScript actually checks that the object matches Config, while still keeping the narrower, inferred type of the literal object itself.
A simple way to remember the difference:
as
Treat this value as if it were this type.
satisfies
Confirm that this value meets the requirements of this type.
That's what makes satisfies especially handy for configuration objects, static mappings, and lookup tables.
10. Treat as as a Boundary, Not a Default Tool
There are moments where a type assertion is genuinely required. But writing something like this:
const user = response as User
doesn't actually check anything at runtime.
Suppose an API call really returned:
{
username: 'akshat'
}
TypeScript has no way of catching the mismatch, because the assertion already told it to accept the value at face value. Nothing is being proven to the compiler here — it's simply being asked to look away.
This gets risky in places where data crosses from outside the codebase into it, such as:
- API responses
- localStorage
- URL parameters
- environment variables
- user input
- third-party libraries
Whenever data enters an application from a source TypeScript can't see into, it's worth validating that data rather than casting it. A runtime schema check can actually confirm something like:
"This data actually matches the shape the application expects."
That's a far stronger guarantee than simply writing:
value as User
TypeScript is a compile-time tool, and a very good one. It was never built to validate what happens while a program runs.
The Real Goal: Model the Domain
Once this mindset clicks, TypeScript stops feeling like a syntax exercise. Instead of asking "how do I type this object?", you start asking "what states can this object actually be in?" Instead of asking "should this property be optional?", you start asking "is this property truly optional, or is it just hiding something that isn't known yet?" Instead of asking "can as be used here?", you start asking "can this value actually be proven to have the type being claimed?"
That shift is the whole point. Writing good TypeScript isn't about piling on more type annotations — it's about making the types you do write actually mean something.
A Simple Rule to Remember
Whenever you design a type, run it through three questions:
1. What states are actually valid?
If a type allows invalid states to be represented, the model itself likely needs rethinking.
2. What does the compiler already know?
Avoid annotating things purely out of habit — let inference do the work it's already capable of.
3. Where does this data become trustworthy?
The further data travels from its original external source, the more certainty its types should be allowed to express.
Strong TypeScript isn't defined by how elaborate its types are. It's defined by types that make the correct implementation obvious and the incorrect one awkward to write.
Once types are designed with this mindset, TypeScript stops feeling like a layer bolted onto JavaScript. It becomes part of how an application is actually built.