Vinicius Aguiar
TypeScript

Getting started with TypeScript: a practical guide for JS developers

Apr 16, 2026 · 12 min read

If you write JavaScript and don't use TypeScript yet, you're giving up the most effective tool to prevent production bugs. TypeScript is not a different language — it's JavaScript with static typing. The compiler warns you about errors before the code runs, not after. In this guide, I present everything you need to start using TypeScript in real projects.

Why TypeScript

JavaScript is dynamic — any variable can be anything at any time. This is flexible but dangerous. You only discover errors when the code runs and the user sees the error.

// JavaScript: this runs without error
function calculateTotal(price, quantity) {
  return price * quantity
}

calculateTotal('10', 2) // Returns '102' (string), not 20
calculateTotal(10)      // Returns NaN, no warning

TypeScript catches these errors before running:

// TypeScript: the compiler warns you
function calculateTotal(price: number, quantity: number): number {
  return price * quantity
}

calculateTotal('10', 2) // ✗ Error: Argument of type 'string' is not assignable to 'number'
calculateTotal(10)      // ✗ Error: Expected 2 arguments, but got 1

Basic types

TypeScript primitive types map directly to JavaScript types:

// Primitives
let name: string = 'Vinicius'
let age: number = 24
let isActive: boolean = true

// Arrays
let tags: string[] = ['react', 'next', 'typescript']
let scores: number[] = [95, 87, 92]

// Inline objects
let user: { name: string; email: string } = {
  name: 'Vinicius',
  email: 'vini@email.com'
}

In practice, you almost never need to type variables explicitly — TypeScript infers the type automatically:

// Automatic inference — TS knows it's a string
let name = 'Vinicius' // type: string
let age = 24           // type: number
let tags = ['react']   // type: string[]

// Only type explicitly when necessary
let data: unknown = await fetchFromAPI()

Interfaces and Types

For more complex objects, use interface or type. The practical difference is minimal — use interface for objects and type for unions and composite types:

// Interface — for objects
interface User {
  id: string
  name: string
  email: string
  role: 'admin' | 'user'
  avatar?: string // optional field
}

// Type — for unions and compositions
type Status = 'pending' | 'confirmed' | 'failed' | 'refunded'

type ApiResponse<T> = {
  data: T
  success: boolean
  error: string | null
}

Interfaces can be extended, which is useful for type inheritance:

interface BaseEntity {
  id: string
  createdAt: Date
  updatedAt: Date
}

interface User extends BaseEntity {
  name: string
  email: string
}

interface Order extends BaseEntity {
  userId: string
  total: number
  status: Status
}

// User now has id, createdAt, updatedAt, name, email

Typed functions

Typing functions is where TypeScript shines the most. The compiler validates arguments and return type:

// Typed parameters and return
function createUser(name: string, email: string): User {
  return {
    id: crypto.randomUUID(),
    name,
    email,
    role: 'user',
    createdAt: new Date(),
    updatedAt: new Date()
  }
}

// Arrow function
const formatPrice = (cents: number): string => {
  return `R$ ${(cents / 100).toFixed(2)}`
}

// Async function
async function getUser(id: string): Promise<User | null> {
  const res = await fetch(`/api/users/${id}`)
  if (!res.ok) return null
  return res.json()
}

Generics: reusable types

Generics let you create functions and types that work with any type while keeping type safety. It's the most powerful TypeScript concept:

// Without generics — would need one function per type
function getFirstString(arr: string[]): string | undefined {
  return arr[0]
}
function getFirstNumber(arr: number[]): number | undefined {
  return arr[0]
}

// With generics — one function for all types
function getFirst<T>(arr: T[]): T | undefined {
  return arr[0]
}

getFirst(['a', 'b', 'c']) // type: string | undefined
getFirst([1, 2, 3])       // type: number | undefined
getFirst<User>(users)     // type: User | undefined

Generics are essential for API functions, React hooks, and any reusable code:

// Generic API response
async function fetchAPI<T>(endpoint: string): Promise<ApiResponse<T>> {
  const res = await fetch(`/api${endpoint}`)
  const data = await res.json()
  return data as ApiResponse<T>
}

// Usage — TS knows the return type
const { data: user } = await fetchAPI<User>('/users/123')
// user is of type User

const { data: orders } = await fetchAPI<Order[]>('/orders')
// orders is of type Order[]

Utility Types: transforming types

TypeScript comes with utility types that transform existing types. These are the ones I use daily:

interface User {
  id: string
  name: string
  email: string
  role: 'admin' | 'user'
}

// Partial — all fields become optional
type UpdateUser = Partial<User>
// { id?: string; name?: string; email?: string; role?: ... }

// Pick — select specific fields
type UserPreview = Pick<User, 'id' | 'name'>
// { id: string; name: string }

// Omit — remove specific fields
type CreateUser = Omit<User, 'id'>
// { name: string; email: string; role: 'admin' | 'user' }

// Record — create typed object
type UserMap = Record<string, User>
// { [key: string]: User }

The most common use is in forms and APIs — where you need variations of the same type:

// API: create user (no id, database generates it)
async function createUser(data: Omit<User, 'id'>): Promise<User> {
  const res = await fetch('/api/users', {
    method: 'POST',
    body: JSON.stringify(data)
  })
  return res.json()
}

// API: partial update (all fields optional)
async function updateUser(id: string, data: Partial<User>): Promise<User> {
  const res = await fetch(`/api/users/${id}`, {
    method: 'PATCH',
    body: JSON.stringify(data)
  })
  return res.json()
}

Enums vs Union Types

Enums exist in TypeScript but in practice, union types are simpler and don't generate extra JavaScript code:

// ❌ Enum — generates extra JS code, more complex
enum PaymentStatus {
  PENDING = 'pending',
  CONFIRMED = 'confirmed',
  FAILED = 'failed',
}

// ✅ Union type — simpler, zero overhead
type PaymentStatus = 'pending' | 'confirmed' | 'failed'

// Both work the same for validation:
function processPayment(status: PaymentStatus) {
  switch (status) {
    case 'pending':
      return 'Waiting...'
    case 'confirmed':
      return 'Confirmed!'
    case 'failed':
      return 'Failed.'
  }
}

Type guards: narrowing types

When a value can be of multiple types, type guards help TypeScript understand which type it is at each moment:

type Result = { success: true; data: User } | { success: false; error: string }

function handleResult(result: Result) {
  if (result.success) {
    // TS knows result.data exists here
    console.log(result.data.name)
  } else {
    // TS knows result.error exists here
    console.error(result.error)
  }
}

// Custom type guard
function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'name' in value &&
    'email' in value
  )
}

const data: unknown = await fetchSomething()
if (isUser(data)) {
  // TS knows data is User here
  console.log(data.email)
}

Configuration: tsconfig.json

The tsconfig.json defines how TypeScript behaves in the project. For modern projects with Next.js or Node.js, this is the base I recommend:

{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "strict": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "incremental": true,
    "paths": {
      "@/*": ["./*"]
    }
  },
  "include": ["**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

The most important setting is "strict": true — it activates all type checks. Projects without strict mode lose most of TypeScript's value.

Patterns I use in production

After using TypeScript in every project, these are the patterns I apply most in my daily work:

  • **Never use any** — use unknown when you don't know the type and type guard to validate
  • Inference first — let TS infer the type whenever possible, only type explicitly when necessary
  • Interfaces for component props — every React component prop should have an interface
  • Utility types for APIsOmit<User, 'id'> for creation, Partial<User> for updates
  • Union types instead of enums — simpler, no runtime overhead
  • **as const** — for literal arrays and objects that shouldn't change
// as const — turns values into literal types
const ROLES = ['admin', 'user', 'moderator'] as const
type Role = typeof ROLES[number] // 'admin' | 'user' | 'moderator'

// React component props
interface ButtonProps {
  label: string
  variant?: 'primary' | 'secondary' | 'ghost'
  onClick: () => void
  disabled?: boolean
}

function Button({ label, variant = 'primary', onClick, disabled }: ButtonProps) {
  return (
    <button onClick={onClick} disabled={disabled} className={variant}>
      {label}
    </button>
  )
}

Conclusion

TypeScript is not about writing more code — it's about writing code the compiler can validate. Types document intent, interfaces define contracts between modules, and generics enable reuse without losing safety. If you come from JavaScript, the learning curve is smooth: start by typing functions and props, then advance to generics and utility types. The gain in productivity and code confidence shows up fast.