Development

TypeScript in 2025: practices that separate senior teams from junior ones

By Alternetica Team··8 min read

The difference between a team that uses TypeScript as "JavaScript with optional types" and a team that truly leverages the type system is enormous in terms of maintainability, reliability, and development speed. These are the patterns we use at Alternetica and that should be in every TypeScript developer's repertoire in 2025.

The satisfies operator: precision without type loss

Introduced in TypeScript 4.9, satisfies validates that a value satisfies a type without changing the inferred type of the value. It sounds subtle, but the difference is significant.

// The problem with direct annotation
type Config = Record<string, string | number>
const config: Config = {
  host: "localhost",
  port: 5432,
  name: "my_db"
}
// config.port is string | number — we lose precision
config.port.toFixed(2) // Error: string has no toFixed

// With satisfies: validation without losing the inferred type
const config2 = {
  host: "localhost",
  port: 5432,
  name: "my_db"
} satisfies Config
// config2.port is number (correctly inferred)
config2.port.toFixed(2) // Works

satisfies is especially useful for configuration objects, route maps, and any structure where you want to validate against a type but keep the precision of the inferred type.

Template Literal Types for typed APIs

Template literal types let you build string types with surgical precision.

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
type ApiVersion = 'v1' | 'v2'
type ApiEndpoint = `/${ApiVersion}/${string}`

// Typed event types for a notification system
type EntityType = 'user' | 'order' | 'product' | 'invoice'
type EventAction = 'created' | 'updated' | 'deleted' | 'processed'
type EventType = `${EntityType}:${EventAction}`

// EventType is exactly the union of all combinations
const event: EventType = 'order:created'   // OK
const event2: EventType = 'order:invented' // Error

// Useful for permission systems
type Resource = 'users' | 'orders' | 'reports'
type Permission = `${Resource}:read` | `${Resource}:write` | `${Resource}:delete`

function checkPermission(user: User, permission: Permission): boolean {
  return user.permissions.includes(permission)
}

Conditional types for reusable type logic

Conditional types are the "if/else" of the type system.

// Unwrap the array element type if it's an array, or the type itself if not
type Unwrap<T> = T extends (infer U)[] ? U : T

type A = Unwrap<string[]>  // string
type B = Unwrap<string>    // string
type C = Unwrap<User[]>    // User

// Make all properties deeply optional (DeepPartial)
type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T

// Extract the resolved type from a Promise
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T

// Get only the keys whose value matches a certain type
type KeysOfType<T, V> = {
  [K in keyof T]: T[K] extends V ? K : never
}[keyof T]

interface User {
  id: number
  name: string
  email: string
  active: boolean
  credit: number
}

type StringKeys = KeysOfType<User, string>
// = 'name' | 'email'

The infer keyword: inference inside conditional types

infer is the tool for extracting types from complex structures.

// Extract a function's parameter types
type Parameters<T extends (...args: any) => any> =
  T extends (...args: infer P) => any ? P : never

// Extract the return type
type ReturnType<T extends (...args: any) => any> =
  T extends (...args: any) => infer R ? R : never

// Practical case: extract data type from an API response
type ApiResponse<T> = {
  data: T
  status: number
  message: string
}

type ExtractData<T> = T extends ApiResponse<infer D> ? D : never

type UserResponse = ApiResponse<User>
type UserData = ExtractData<UserResponse>  // = User

Branded Types for domain modeling

Branded types prevent accidentally mixing types that share the same structure but carry different meanings.

// Without branded types: these types are interchangeable (bad)
type UserId = string
type OrderId = string

function getOrder(userId: UserId, orderId: OrderId) { /* ... */ }
// Nothing stops you from calling: getOrder(orderId, userId) -- silent bug

// With branded types: TypeScript catches the mistake
declare const _brand: unique symbol
type Brand<T, B> = T & { readonly [_brand]: B }

type UserId = Brand<string, 'UserId'>
type OrderId = Brand<string, 'OrderId'>
type ProductId = Brand<number, 'ProductId'>

function createUserId(id: string): UserId {
  return id as UserId
}

function getOrder(userId: UserId, orderId: OrderId) { /* ... */ }

const uid = createUserId('user-123')
const oid = 'ord-456' as OrderId

getOrder(oid, uid)  // TypeScript error
getOrder(uid, oid)  // OK

Branded types are especially valuable in systems with multiple ID types or units of measurement (liters vs kilograms, COP vs USD).

Discriminated Unions for state modeling

The discriminated union pattern makes it impossible to access data that does not exist in the current state.

// Without discriminated union: the type does not model reality
interface Request {
  status: 'pending' | 'approved' | 'rejected'
  rejection_reason?: string  // Only exists if rejected
  approved_by?: string       // Only exists if approved
}

// With discriminated union: each state has exactly the right fields
type Request =
  | { status: 'pending'; request_date: Date }
  | { status: 'approved'; approved_by: string; approval_date: Date }
  | { status: 'rejected'; reason: string; rejected_by: string }

function processRequest(request: Request) {
  switch (request.status) {
    case 'pending':
      // TypeScript knows only request_date exists here
      console.log(request.request_date)
      break
    case 'approved':
      // Can only access approved_by and approval_date
      console.log(request.approved_by)
      break
    case 'rejected':
      console.log(request.reason)
      break
  }
}

Zod for runtime validation

TypeScript disappears at runtime. Zod adds runtime validation with automatic type inference.

import { z } from 'zod'

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(2).max(100),
  email: z.string().email(),
  role: z.enum(['admin', 'editor', 'viewer']),
  age: z.number().min(18).optional(),
  created_at: z.coerce.date()
})

// Type inferred automatically — never out of sync
type User = z.infer<typeof UserSchema>

// Runtime validation (e.g., data from an external API)
const result = UserSchema.safeParse(unknownData)
if (result.success) {
  // result.data is of type User with full type safety
  console.log(result.data.email)
} else {
  // result.error contains detailed errors
  console.error(result.error.format())
}

Common junior developer mistakes

1. Using any as an escape hatch: any disables the type system. Use unknown with type guards instead.

2. Unnecessary as assertions: value as Type is a lie to the compiler. Only use it when you genuinely know more than TypeScript does.

3. Empty interfaces for extending: interface MyType extends {} {} is an anti-pattern. Use type MyType = {} or model the type correctly.

4. Not using standard utility types: Partial<T>, Required<T>, Pick<T, K>, Omit<T, K>, Record<K, V>, Readonly<T>. Knowing them saves hours.

5. Overly broad parameter types: function process(data: object) vs function process(data: { id: string; name: string }). Precision in parameters makes code self-documenting.

Conclusion: the type system as living documentation

A well-designed TypeScript type system makes code self-documenting and makes errors impossible to commit rather than merely detectable. The investment in learning these patterns pays off quickly in less debugging time and greater confidence when refactoring.

At Alternetica all our projects use TypeScript in strict mode from day one. If your team is adopting TypeScript or you want a code review of an existing architecture, contact us.

Let's talk with no strings attached

Ready to take the next technology step?

Tell us your challenge. In less than 24 hours you'll hear from one of our senior engineers to analyze how we can help you.

No initial commitmentResponse in less than 24 hoursSenior engineers from day one