Most developers treat types like bags. They hold stuff.
Great developers treat types like barriers. They decide what gets through.
Design types to shape what the system allows not to mirror the world. Strip away what doesn't belong. Don't include everything the backend returns. Let only the essential fields survive.
Your job isn't to mirror reality.
Your job is to reduce it.
Each type should express a choice.
Each model is a decision.
Not what could be but what must be true.
What follows are seven ways to write types that clarify, constrain, and protect.
Types that teach.
Types that prevent.
Types that leave nothing to chance.
1. Model for Meaning, Not Just Shape
The structure tells you what's present.
Types tell you what's trusted.
Before:
type User = {
name: string;
email: string;
password: string;
};
After:
type Email = Brand<string, "Email">;
type HashedPassword = Brand<string, "HashedPassword">;
type NewUser = {
readonly name: string;
readonly email: Email;
readonly password: HashedPassword;
};
A string
is vague.
An Email
is verified.
A HashedPassword
is processed.
Types aren't just containers.
They're commitments.
2. Make Wrong Assignments Impossible
JavaScript trusts everything.
TypeScript lets you define what not to trust.
type Brand<T, U> = T & { __brand: U };
type Email = Brand<string, "Email">;
type UserId = Brand<string, "UserId">;
function sendEmail(to: Email) { /* ... */ }
const userId = "123" as UserId;
sendEmail(userId); // ❌ Type error
The type system becomes a security layer.
One enforced at compile time.
Create branded values using constructor functions:
function createEmail(str: string): Email | null;
Build trust at the edge.
Enforce the truth inside.
3. Replace Flags with Variants
Booleans whisper.
Variants speak with clarity.
Before:
type Notification = {
type: "email" | "sms";
isUrgent: boolean;
};
After:
type PhoneNumber = Brand<string, "PhoneNumber">;
type Notification =
| { readonly kind: "UrgentEmail"; readonly address: Email }
| { readonly kind: "UrgentSMS"; readonly phone: PhoneNumber }
| { readonly kind: "RegularEmail"; readonly address: Email }
| { readonly kind: "RegularSMS"; readonly phone: PhoneNumber };
Flags invite ambiguity.
Variants remove it.
The kind
tells the whole story.
4. Let the State Enforce Its Own Rules
When you encode state loosely, contradictions creep in.
Design types that make contradictions impossible.
Before:
type Upload = {
progress?: number;
error?: string;
file?: File;
};
After:
type Upload =
| { readonly status: "idle" }
| { readonly status: "uploading"; readonly progress: number }
| { readonly status: "error"; readonly error: string }
| { readonly status: "complete"; readonly file: File };
Each branch holds exactly what it should, nothing more.
No undefined checks. No overlap. No guesswork.
Just truth, encoded.
5. Encode Time as a Type
The state is a timeline.
Design it that way.
type Session =
| { readonly kind: "Unauthenticated" }
| { readonly kind: "Authenticated"; readonly user: User };
function viewDashboard(session: Session) {
if (session.kind === "Unauthenticated") return redirect("/login");
return renderDashboard(session.user);
}
Instead of checking what exists, you define when it exists.
The compiler becomes your clock.
6. Pass Intent, Not Objects
Don't pass what you have.
Pass what you mean to do.
Before:
function updateUser(user: User) { /* ... */ }
After:
type UpdateUserInput = {
readonly userId: UserId;
readonly changes: Partial<Pick<User, "name" | "email">>;
};
function updateUser(input: UpdateUserInput) { /* ... */ }
Not a blob of data but a command.
Define intent clearly and prevent mistakes from hiding.
7. Require Proof, Not Checks
Booleans can lie.
Types can prove.
Before:
function deleteUser(id: string) { /* ... */ }
After:
type Token = Brand<string, "Token">;
type VerifiedAdmin = {
readonly kind: "Admin";
readonly token: Token;
};
function deleteUser(admin: VerifiedAdmin, id: UserId) { /* ... */ }
This type doesn't ask, "Is this user an admin?"
It requires that you prove it.
Types don't just describe.
They gate. They guard. They govern.
Branded Type Reference
type Brand<T, U> = T & { __brand: U };
type Email = Brand<string, "Email">;
type UserId = Brand<string, "UserId">;
type PhoneNumber = Brand<string, "PhoneNumber">;
type HashedPassword = Brand<string, "HashedPassword">;
type Token = Brand<string, "Token">;
A branded type isn't just a constraint.
It's a contract you enforce once, then trust forever.
Consider This
If your types disappeared, would your system still be safe?
Are you modeling what exists or what matters?
Do your types describe the world or define it?
Closing Thought
Types aren't just syntax.
They're boundaries that protect decisions.
They're permissions expressed in shape.
A great type doesn't say maybe.
It says only.
Design types that leave no room for doubt.
Design types that make the following change safe by default.