Ismat Samadov
  • Tags
  • About
15 min read/1 views

TypeScript Tips I Wish I Knew 2 Years Ago

10 TypeScript patterns that eliminated runtime errors from my codebase: as const, satisfies, discriminated unions, Zod, and more.

TypeScriptJavaScriptWeb DevFrontend

Related Articles

Temporal for Backend Developers: Durable Execution Makes Complex Backends Boring

18 min read

EU AI Act Hits August 2026: Most Companies Are Not Ready (Compliance Checklist for Devs)

19 min read

Playwright Won the Browser Testing Wars — Here's the Only Guide You Need

17 min read

Enjoyed this article?

Get new posts delivered to your inbox. No spam, unsubscribe anytime.

On this page

  • 1. Stop Using `any`. Use `unknown` Instead.
  • 2. `as const` Changes Everything
  • 3. The `satisfies` Operator Is Underrated
  • 4. Discriminated Unions Kill Entire Categories of Bugs
  • Exhaustiveness checking with `never`
  • 5. Utility Types Are Not Optional Reading
  • 6. Zod: Runtime Validation That Generates Your Types
  • 7. Stop Writing Interfaces for Everything
  • 8. Generic Constraints Are Your Best Friend
  • 9. Enable Strict Mode From Day One
  • 10. The `infer` Keyword Unlocks Advanced Patterns
  • The Practical Upgrade Path
  • What I Actually Think
  • Sources

© 2026 Ismat Samadov

RSS

Two years ago I shipped a Next.js app with 47 instances of any in the codebase. I knew it was bad. I told myself I'd fix it later. "Later" turned into six months of chasing runtime errors that TypeScript could've caught at compile time — if I'd known what I know now.

TypeScript went from "nice to have" to the most-used language on GitHub in 2025, with 2.6 million monthly active contributors. About 40% of JavaScript developers now write exclusively in TypeScript, up from 28% in 2022. It's not optional anymore. It's the default.

But here's the problem: most TypeScript tutorials teach you the basics — interfaces, generics, type annotations — and then leave you to figure out the rest. The patterns that actually save you time in production? Those you learn by making mistakes. Or by reading an article like this one.

These are the TypeScript tips I wish someone had handed me two years ago.


1. Stop Using any. Use unknown Instead.

This is the most common TypeScript mistake, and I made it constantly. When the type system fought me, I reached for any like a panic button.

// What I used to write (bad)
function parseJSON(input: string): any {
  return JSON.parse(input)
}

const data = parseJSON('{"name": "Ismat"}')
data.whatever.doesnt.exist // No error. Runtime crash.

any turns off type checking entirely. You're telling TypeScript: "I don't care. Don't help me." That defeats the entire purpose.

unknown is the type-safe alternative. It accepts any value, but forces you to narrow the type before using it.

// What I write now (good)
function parseJSON(input: string): unknown {
  return JSON.parse(input)
}

const data = parseJSON('{"name": "Ismat"}')

// TypeScript forces you to check the type first
if (typeof data === 'object' && data !== null && 'name' in data) {
  console.log((data as { name: string }).name) // Safe
}

Is it more code? Yes. Does it prevent the runtime errors that cost you hours of debugging? Also yes. Every any in your codebase is a landmine you planted for your future self.


2. as const Changes Everything

This was the single biggest "wait, WHAT?" moment in my TypeScript journey. I spent months writing unnecessary enums and extra type definitions when as const would've done the job in one line.

Without as const:

const config = {
  endpoint: '/api/users',
  method: 'GET',
  retries: 3,
}
// TypeScript infers: { endpoint: string; method: string; retries: number }
// "GET" widens to string. You lose the specific value.

With as const:

const config = {
  endpoint: '/api/users',
  method: 'GET',
  retries: 3,
} as const
// TypeScript infers: { readonly endpoint: '/api/users'; readonly method: 'GET'; readonly retries: 3 }
// Every value preserves its literal type. The object is deeply readonly.

This is incredibly useful for:

  • Route definitions — where you want exact string types, not just string
  • Configuration objects — where values shouldn't change
  • Dropdown options and select menus — where you need the exact values as a union type
  • API method types — where "GET" should be "GET", not string

Here's a real pattern I use constantly:

const STATUS_CODES = ['active', 'inactive', 'pending', 'archived'] as const

// Derive the union type automatically
type StatusCode = (typeof STATUS_CODES)[number]
// Result: 'active' | 'inactive' | 'pending' | 'archived'

// Now you can use it as both a runtime array AND a type
function setStatus(status: StatusCode) {
  // TypeScript ensures only valid values are passed
}

// And iterate over it at runtime
STATUS_CODES.forEach(code => console.log(code))

One source of truth. No enum. No duplicate type definition. Just as const and an indexed access type.


3. The satisfies Operator Is Underrated

Added in TypeScript 4.9, satisfies is one of the most powerful features that most developers ignore. I didn't use it for a year after it shipped because I didn't understand why I'd need it.

Here's the problem it solves. Say you have a config object and you want to make sure it matches a type — but you don't want to lose the specific types of each value.

type RouteConfig = Record<string, { path: string; auth: boolean }>

// With type annotation — you lose specifics
const routes: RouteConfig = {
  home: { path: '/', auth: false },
  dashboard: { path: '/dashboard', auth: true },
}

routes.home.path // Type: string (lost the literal '/')
routes.typo       // No error! RouteConfig allows any string key
// With satisfies — you keep specifics AND get validation
const routes = {
  home: { path: '/', auth: false },
  dashboard: { path: '/dashboard', auth: true },
} satisfies RouteConfig

routes.home.path   // Type: '/' (preserved the literal!)
routes.typo        // Error! Property 'typo' does not exist

The satisfies operator validates that an expression conforms to a target type while preserving the expression's precise type info. You get compile-time validation without losing inference. Best of both worlds.

I now use satisfies everywhere I used to use type annotations for object literals.


4. Discriminated Unions Kill Entire Categories of Bugs

This pattern alone would've saved me weeks of debugging. A discriminated union is a union type where each member has a common property (the "discriminant") with a literal type.

// Instead of this (bad)
type ApiResponse = {
  status: 'success' | 'error'
  data?: unknown
  error?: string
  retryAfter?: number
}

// You never know which fields exist for which status.
// data might be undefined on success. error might exist on success.
// It's chaos.
// Do this (good)
type ApiResponse =
  | { status: 'success'; data: unknown }
  | { status: 'error'; error: string; retryAfter?: number }

function handleResponse(res: ApiResponse) {
  if (res.status === 'success') {
    console.log(res.data)       // TypeScript KNOWS data exists
    // console.log(res.error)   // Error! 'error' doesn't exist on success
  } else {
    console.log(res.error)      // TypeScript KNOWS error exists
    console.log(res.retryAfter) // Optional, but correctly typed
  }
}

TypeScript narrows the type automatically based on the discriminant. No type assertions. No optional chaining on fields that should definitely exist. No if (response.data) checks scattered everywhere.

Discriminated unions force you to handle every case, and if you add a new variant later, TypeScript tells you everywhere you forgot to handle it.

Exhaustiveness checking with never

This is the killer feature. Add a default case that assigns to never, and TypeScript will error if you miss a case:

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
  | { kind: 'triangle'; base: number; height: number }

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2
    case 'square':
      return shape.side ** 2
    case 'triangle':
      return 0.5 * shape.base * shape.height
    default:
      const _exhaustive: never = shape
      return _exhaustive
  }
}

// If someone adds { kind: 'rectangle'; ... } to Shape,
// TypeScript errors here until you add the case. Automatic.

5. Utility Types Are Not Optional Reading

TypeScript ships with built-in utility types that most people learn one at a time over years. I'm going to save you that time.

Utility TypeWhat It DoesWhen I Use It
Partial<T>Makes all properties optionalForm state, patch/update DTOs
Required<T>Makes all properties requiredEnsuring complete config before use
Pick<T, K>Selects specific propertiesAPI response subsets
Omit<T, K>Removes specific propertiesCreating DTOs without internal fields
Record<K, V>Object with typed keys and valuesLookup maps, indexed configs
Readonly<T>Makes all properties readonlyImmutable state, constants
ReturnType<T>Extracts a function's return typeTyping hook returns, service responses
Parameters<T>Extracts a function's parameter typesWrapping functions, middleware
Awaited<T>Unwraps Promise typesTyping async function results
NonNullable<T>Removes null and undefinedPost-validation types

The ones I use most? Omit, Pick, and ReturnType. They let you derive types from existing types instead of duplicating definitions.

// Real example: deriving API types from the database model
type User = {
  id: string
  email: string
  passwordHash: string
  name: string
  createdAt: Date
}

// API response — strip internal fields
type PublicUser = Omit<User, 'passwordHash'>

// Create user input — strip auto-generated fields
type CreateUserInput = Omit<User, 'id' | 'createdAt'>

// Update — everything optional except id
type UpdateUserInput = Partial<Omit<User, 'id'>> & Pick<User, 'id'>

One source type. Multiple derived types. Change the source and everything updates automatically.


6. Zod: Runtime Validation That Generates Your Types

TypeScript types disappear at runtime. They're compile-time only. So when data comes from an API, a form, or a database query, TypeScript can't help you. You're on your own.

That's where Zod comes in. Define a schema once, get runtime validation AND TypeScript types from the same definition.

import { z } from 'zod'

// Define the schema
const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string().min(1).max(100),
  role: z.enum(['admin', 'user', 'viewer']),
  createdAt: z.coerce.date(),
})

// Derive the TypeScript type automatically
type User = z.infer<typeof UserSchema>
// Result: { id: string; email: string; name: string; role: 'admin' | 'user' | 'viewer'; createdAt: Date }

// Validate at runtime
function createUser(input: unknown): User {
  return UserSchema.parse(input)
  // Throws ZodError if validation fails
  // Returns strongly-typed User if it passes
}

I use Zod at every system boundary:

  • API route handlers — validate request bodies
  • Environment variables — validate process.env at startup
  • Database query results — validate after complex joins
  • Form submissions — validate on both client and server

Before Zod, I had TypeScript types AND separate validation logic that could drift out of sync. Now they're the same thing. Zod changed how we think about TypeScript validation by eliminating the gap between compile-time types and runtime guarantees.

Quick note: Zod isn't the only option in 2026. Valibot offers smaller bundle sizes, and ArkType brings even tighter TypeScript integration. But Zod's ecosystem and documentation are still the most mature.


7. Stop Writing Interfaces for Everything

Hot take: I use type for almost everything now, and interface only when I specifically need declaration merging or class implementation.

// interface can be reopened (declaration merging)
interface Window {
  myCustomProp: string
}
// This extends the existing Window interface

// type cannot be reopened — it's sealed
type User = { name: string }
// type User = { email: string } // Error: duplicate identifier

For 95% of application code — props, state, API responses, function arguments — type works perfectly and has two advantages:

  1. Union and intersection types — only type can do type Result = Success | Error
  2. It can't be accidentally extended — which is usually what you want

The TypeScript docs themselves say that for most cases, you can choose between them based on personal preference. My preference: type by default, interface when you need merging.


8. Generic Constraints Are Your Best Friend

Raw generics are too permissive. Constrained generics are where the real power lives.

// Too permissive — T could be anything
function getProperty<T>(obj: T, key: string): unknown {
  return (obj as any)[key]
}

// Constrained — T must be an object, K must be a valid key
function getProperty<T extends object, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]
}

const user = { name: 'Ismat', age: 28 }
getProperty(user, 'name')   // Return type: string
getProperty(user, 'age')    // Return type: number
getProperty(user, 'email')  // Error: 'email' is not a key of user

The constrained version catches invalid keys at compile time AND returns the correct type for each property. No any. No type assertions. Pure inference.

Here's another pattern I use for API wrappers:

type ApiEndpoints = {
  '/users': { response: User[]; params: { page: number } }
  '/users/:id': { response: User; params: { id: string } }
  '/posts': { response: Post[]; params: { limit: number } }
}

async function api<T extends keyof ApiEndpoints>(
  endpoint: T,
  params: ApiEndpoints[T]['params']
): Promise<ApiEndpoints[T]['response']> {
  // implementation
  const res = await fetch(endpoint)
  return res.json()
}

// Fully typed — endpoint, params, AND return type
const users = await api('/users', { page: 1 })     // User[]
const user = await api('/users/:id', { id: '123' }) // User
const posts = await api('/posts', { limit: 10 })    // Post[]

One function. Type-safe for every endpoint. Autocomplete for both the URL and the parameters.


9. Enable Strict Mode From Day One

I used to start projects with strict mode off because it "slowed me down." That's like removing the guardrails on a mountain road because they slow your driving.

Strict mode enables a suite of type-checking options that catch subtle bugs most developers miss:

FlagWhat It Does
strictNullChecksnull and undefined must be handled explicitly
noImplicitAnyEvery value must have a type — no silent any
strictFunctionTypesFunction parameter types checked correctly
noImplicitReturnsEvery code path must return a value
strictPropertyInitializationClass properties must be initialized
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "forceConsistentCasingInFileNames": true
  }
}

That noUncheckedIndexedAccess flag is the one most people miss. Without it:

const arr = [1, 2, 3]
const val = arr[10] // Type: number (wrong! it's undefined)

With it:

const arr = [1, 2, 3]
const val = arr[10] // Type: number | undefined (correct!)

If you're starting a new project in 2026, there's no excuse not to use strict mode. TypeScript 5.9 even added a --strictInference flag that tightens inference in ambiguous scenarios. The compiler is getting smarter. Let it help you.


10. The infer Keyword Unlocks Advanced Patterns

This is the tip that separates intermediate TypeScript from advanced TypeScript. The infer keyword lets you extract types from within conditional types — think of it as pattern matching for types.

// Extract the element type of an array
type ElementOf<T> = T extends (infer E)[] ? E : never

type A = ElementOf<string[]>   // string
type B = ElementOf<number[]>   // number
type C = ElementOf<boolean>    // never (not an array)
// Extract the resolved type of a Promise
type Unwrap<T> = T extends Promise<infer U> ? U : T

type D = Unwrap<Promise<string>>   // string
type E = Unwrap<Promise<number[]>> // number[]
type F = Unwrap<string>            // string (passthrough)

Where this gets practical: extracting prop types from React components you don't control.

// Extract props from any React component
type PropsOf<T> = T extends React.ComponentType<infer P> ? P : never

// Now you can type a wrapper without importing the props type
type ButtonProps = PropsOf<typeof Button>

You won't need infer every day. But when you do — building reusable type utilities, wrapping third-party libraries, creating type-safe APIs — nothing else can do the job.


The Practical Upgrade Path

If you're sitting on a codebase with loose TypeScript, here's the order I'd tackle these tips:

  1. Week 1: Enable strict: true and noUncheckedIndexedAccess. Fix the resulting errors.
  2. Week 2: Replace every any with unknown or a proper type. Search your codebase for : any and kill them one by one.
  3. Week 3: Add Zod to your API boundaries. Start with route handlers and environment variable validation.
  4. Week 4: Refactor your biggest type file to use satisfies, as const, and utility types. Derived types instead of duplicated types.
  5. Ongoing: Learn discriminated unions and use them for every state machine, API response, and form state.

This isn't a weekend project. It's a gradual improvement. But each step makes the next one easier, and within a month your codebase will be catching bugs that used to reach production.

A useful command to track your progress: search your codebase for : any and count the results. I started at 47. After four weeks, I was down to 3 (all in third-party type definitions I couldn't control). That number is the single best indicator of how much value you're actually getting from TypeScript.

One more thing: don't try to learn all of this from documentation alone. Write a small utility library with strict mode on and force yourself to use generics, infer, and conditional types. You'll learn more in a weekend of building than in a month of reading.


What I Actually Think

TypeScript's type system is one of the most powerful in any mainstream language. But most developers use maybe 20% of it. They write interfaces, add type annotations to function parameters, and call it a day. Then they wonder why they're still hitting runtime type errors.

The 10 tips in this article aren't esoteric type gymnastics. They're patterns I use every single day in production code. as const and satisfies together have probably saved me more debugging time than any other tool in my stack. Discriminated unions eliminated entire categories of "undefined is not an object" errors. Zod closed the gap between compile-time and runtime that used to bite me on every API call.

The language is only getting better. TypeScript 7.0 — rewritten in Go — promises 8-10x faster builds. Node.js now supports TypeScript natively. 78% of large development teams use it.

If you're still writing loose TypeScript with any everywhere and strict mode off, you're leaving the best parts of the language on the table. Turn on strict. Kill the anys. Learn the type system. It's the best investment you can make as a JavaScript developer.


Sources

  1. TypeScript Became the No. 1 Language on GitHub in 2025 — Java Code Geeks
  2. State of JavaScript 2025 Survey — InfoQ
  3. TypeScript Hits 48.8%: GitHub No. 1, 78% of Jobs Require It — ByteIota
  4. Understanding TypeScript's satisfies Operator — Convex
  5. The Case for Discriminated Union Types with TypeScript — Thoughtbot
  6. How Zod Changed TypeScript Validation Forever — Medium
  7. Zod vs Valibot vs ArkType in 2026 — Pockit
  8. TypeScript Strict Mode Won — Mario Rafael Ayala
  9. TypeScript 5.9 New Features Developer Guide 2026 — Digital Applied
  10. TypeScript 5.x to 6.0 Migration Guide — GitHub
  11. Conditional Types — TypeScript Documentation
  12. TypeScript Everyday Types — TypeScript Documentation