TypeScript at scale: why types are code, not comments
The TypeScript we know vs. the TypeScript that does work
Most teams that adopt TypeScript start treating it as a passive linter. Annotations here, any there, happy that the editor shows them the type of the email field. That’s fine — it’s better than raw JavaScript. But it’s not what TypeScript was built for.
Real-work TypeScript encodes the rules your business can’t afford to break into the type system. If the compiler can’t express the rule, someone breaks it at 3 AM.
Case 1: impossible states should be impossible
Look at this common pattern:
interface User {
id: string
name: string
email?: string
emailVerifiedAt?: Date
}
What happens if email is undefined but emailVerifiedAt isn’t? Invalid. But the type permits it. The bug lives in any branch that assumes one or the other.
Version that encodes reality:
type User = {
id: string
name: string
} & (
| { email: string; emailVerifiedAt: Date }
| { email: string; emailVerifiedAt: null }
| { email: null; emailVerifiedAt: null }
)
Now the compiler rejects the impossible state. Your branch that depended on emailVerifiedAt != null can no longer see email == null.
Real connection: in a DGII integration we had a bug where an e-CF “signed” but “without certificate” reached the endpoint. We fixed it with a type like this. Bug eliminated at the root, not patched.
Case 2: branded types for identities
How many times have you seen a bug where someone passed a customerId where an orderId was expected? Both are string. The compiler doesn’t complain. Welcome to hell.
// Before
function getOrder(orderId: string) { ... }
function shipOrder(customerId: string, orderId: string) { ... }
// Compiles — and breaks in production
shipOrder(orderId, customerId) // Arguments in wrong order
With branded types:
type Brand<T, B> = T & { __brand: B }
type CustomerId = Brand<string, 'CustomerId'>
type OrderId = Brand<string, 'OrderId'>
function getOrder(orderId: OrderId) { ... }
function shipOrder(customerId: CustomerId, orderId: OrderId) { ... }
const customerId = '...' as CustomerId
const orderId = '...' as OrderId
shipOrder(orderId, customerId)
// ❌ Type 'OrderId' is not assignable to parameter of type 'CustomerId'
The only cost: converting string to CustomerId at the boundary (the constructor that receives the raw string from the DB). Once, not in every function.
Case 3: builders with phantom states
In our e-CF SDK, a document moves through states: Draft → Signed → Submitted → Acknowledged. Some operations only apply in certain states:
sign()only inDraft.submit()only inSigned.getAcknowledgement()only inSubmittedor later.
Instead of runtime checks scattered everywhere:
class Comprobante<State extends 'draft' | 'signed' | 'submitted' | 'acknowledged'> {
constructor(private state: State, private data: ComprobanteData) {}
sign(this: Comprobante<'draft'>, cert: Certificate): Comprobante<'signed'> {
return new Comprobante('signed', { ...this.data, signature: cert.sign(this.data) })
}
submit(this: Comprobante<'signed'>): Promise<Comprobante<'submitted'>> {
// only callable if `this` is Comprobante<'signed'>
}
ack(this: Comprobante<'submitted' | 'acknowledged'>): Acknowledgement | null {
// ...
}
}
const c = new Comprobante('draft', data)
c.submit() // ❌ doesn't compile, missing sign()
c.sign(cert).submit() // ✓
The method signature (this: Comprobante<'draft'>) tells TypeScript: this method is only callable when the receiver has this type. Errors that were runtime exceptions become compile errors.
Case 4: satisfies for type-safe configuration
Before TypeScript 4.9 you had to choose between:
- Annotating the type (
const config: Config = {...}) and losing literal types. - Inferring everything (
const config = {...}) and not guaranteeing it matchesConfig.
Today:
const routes = {
home: { path: '/', requiresAuth: false },
dashboard: { path: '/dashboard', requiresAuth: true },
admin: { path: '/admin', requiresAuth: true, role: 'admin' }
} satisfies Record<string, RouteConfig>
routes.home.requiresAuth // type: false (not boolean)
routes.admin.role // type: 'admin' (not string)
satisfies validates that the object matches the type without losing literal information. Ideal for static configuration.
Case 5: discriminated unions for errors
Another frequent trap: treating errors as Error | null. You lose context, you lose type-based recoverability.
// Before
async function chargeCard(): Promise<{ ok: boolean, error?: Error }> { ... }
// After
type ChargeResult =
| { ok: true; transactionId: string; receipt: Receipt }
| { ok: false; error: 'card_declined'; reason: string }
| { ok: false; error: 'rate_limited'; retryAfterMs: number }
| { ok: false; error: 'network_error'; cause: unknown }
const result = await chargeCard()
if (!result.ok) {
switch (result.error) {
case 'card_declined':
return showDeclinedMessage(result.reason)
case 'rate_limited':
return scheduleRetry(result.retryAfterMs)
case 'network_error':
return logToObservability(result.cause)
}
// The compiler guarantees we handle every case.
// If you add a new error, this stops compiling until you handle it.
}
What we pay
TypeScript at this level isn’t free. What it costs:
- Team learning curve. Advanced types (
conditional types,mapped types,template literals) take time to build muscle memory. - Compile time. A codebase with lots of derived types can go from 5s to 30s in
tsc. Worth it, but be aware. - Hard-to-read errors. “Type X is not assignable to type Y” where X has 12 levels of nesting. Learning to unpack those errors is its own skill.
In exchange, what you gain is brutal: bugs that don’t get written because the compiler won’t let them. Refactors that touch 80 files and compile on the first try. New hires where “what’s happening in this flow” is readable directly in the types.
Close
TypeScript isn’t JavaScript with types. It’s a programming language with its own sophisticated type system. Treated as such — encoding your domain rules into the type system — it becomes one of the best ROIs you can give your team.
The simple rule: if a property of the business can be expressed in the type, express it. The compiler doesn’t get tired, doesn’t fall asleep, and doesn’t forget.
— Samuel
Recommended for you