99

Concurrent visitors over the last 30 minutes. (~)

Peak concurrent visitors -
Yesterday: 0 / Today: 0

Essential JS + TS Core Syntax

Essential JS + TS Core Syntax

This post covers the JavaScript and TypeScript syntax you use over and over in web development.
Code in frameworks like React, Vue, and Svelte isn't written in a separate new syntax; it's written on top of this syntax.
So if you're not comfortable with the language itself, you may get stuck while learning a framework, not because of the framework but because of the syntax.
This post is for readers who are just starting to learn JavaScript, or who roughly know the syntax but haven't put it in order yet.

Each piece of syntax is explained in this order: a definition, an example you can run right away, and common mistakes.
The output of the code is shown in comments.
JavaScript comes first, followed by TypeScript, which adds types on top of it.

To look at each topic in more detail, see the JS Function Core Patterns, JS Async Core Patterns, JS Class Core Patterns, and TypeScript at a Glance posts.

# JavaScript

This is JavaScript syntax that runs as is in both the browser and Node.js.
You can run most of the examples by pasting them into the console of your browser's developer tools.

# Variables and scope

Declare variables with const and let.

  • const can't be reassigned (given a different value), while let can.
  • Use const by default, and use let only when you need to assign a new value.
  • var is an old way to declare variables, and we don't use it because of the scope problems explained below.
JS
1
2
3
4
5
const name = 'Kim' let count = 0 count = 1 // let can be reassigned // name = 'Lee' // Error, const can't be reassigned

The range where a variable is valid is called its scope.
const and let are scoped to blocks wrapped in curly braces ({ }), so you can't access them outside the block.

const and let stay inside the block, while var is visible beyond the block, throughout the whole function

JS
1
2
3
4
5
6
7
8
let message = 'outer' if (true) { let message = 'inner' // A separate variable that is valid only inside the block console.log(message) // 'inner' } console.log(message) // 'outer'

var has function scope, so it ignores blocks.
This causes behavior that's hard to predict, such as loop variables leaking out of the loop, so use only const and let in new code.

JS
1
2
3
4
5
for (var i = 0; i < 3; i++) {} console.log(i) // 3, accessible even outside the block for (let j = 0; j < 3; j++) {} // console.log(j) // Error, j is valid only inside the loop block

# Values and references

There are two ways values are stored and handled.

  • Primitive types: number, string, boolean, null, undefined, and so on. The variable stores the value itself.
  • Reference types: objects, arrays, and functions. The variable stores the location of the value (a reference).

Primitive types copy the value, while reference types point to the same object

Because of this difference, comparing and copying give different results.
Primitive values are considered equal if their values are the same, but objects with the same contents are different objects if they're in different locations.

JS
1
2
3
4
5
6
1 === 1 // true 'abc' === 'abc' // true, primitive types are compared by value const obj1 = { a: 1 } const obj2 = { a: 1 } obj1 === obj2 // false, different objects even with the same contents

When you assign an object to another variable, the reference is copied, not the object.
Both variables point to the same object, so if you change it through one, you see the change through the other too.

JS
1
2
3
4
5
const a = [1, 2] const b = a // Points to the same array, not a copy b.push(3) console.log(a) // [1, 2, 3], a changes too

The same goes for passing an object to a function.
If you change an object received as a parameter inside the function, the original outside the function changes.

JS
1
2
3
4
5
6
7
function addItem(cart, item) { cart.push(item) // Changes the received array directly } const cart = ['apple'] addItem(cart, 'banana') console.log(cart) // ['apple', 'banana'], the original changed

How to handle data without changing the original is covered in Immutability and copying.

# Template literals

A template literal is syntax that wraps a string in backticks (the key to the left of the number 1 on the keyboard).
If you write a value or expression inside ${ }, its result is included in the string; this is called interpolation.

JS
1
2
3
4
5
6
7
8
const name = 'Kim' const age = 20 const id = 7 const page = 2 const greeting = `Hello, ${name}` // Variable interpolation const info = `${name} is ${age} and will be ${age + 1} next year` // Expressions can be interpolated too const url = `/api/users/${id}?page=${page}` // '/api/users/7?page=2', often used to build URLs

Line breaks are included in the string as is, so template literals are also used for multi-line strings.

JS
1
2
3
4
5
6
const isActive = true const html = ` <li class="${isActive ? 'active' : ''}"> ${name} </li> ` // class="active" if isActive is true

isActive ? 'active' : '' is the ternary operator, which picks one of two values based on a condition; it's explained in the next section.

# Conditions and short-circuit evaluation

Always use === and !== for comparisons.
== converts types automatically before comparing, which produces unexpected results.

JS
1
2
3
4
'1' == 1 // true, automatic conversion causes confusion '1' === 1 // false, compares types too null == undefined // true null === undefined // false

Conditionals judge values as truthy or falsy.
Only the few values below are treated as falsy, and everything else is truthy.

Category Values
Falsy false, 0, '', null, undefined, NaN, plus -0 and 0n
Truthy Every other value. '0', 'false', [], and {} are truthy too
JS
1
2
3
if (list.length) {} // Doesn't run if the length is 0 if (user) {} // Doesn't run if user is null or undefined if ([]) {} // Runs, because an empty array is truthy too

The ternary operator is an expression that picks one of two values based on a condition.
Unlike an if statement, it returns a value, so you can use it directly in variable assignments and templates.

JS
1
const label = count > 0 ? `${count} items` : 'None'

&& and || are logical operators, but they stop evaluating as soon as the result is determined and return the value at that point.
This is called short-circuit evaluation, and it's often used to pick values or to run something conditionally.

  • A || B: A if A is truthy, otherwise B. Used to set a default value.
  • A && B: A if A is falsy, otherwise B. Used to run B only when A is true.
JS
1
2
const title = user.nickname || 'Anonymous' // 'Anonymous' if there's no nickname isOpen && close() // Calls close only when isOpen is true

|| has a problem: it skips valid values like 0 and '' because they're falsy.
The nullish coalescing operator ?? uses the right-hand value only when the left-hand value is null or undefined.

JS
1
2
const page = options.page || 1 // Becomes 1 if page is 0 const page2 = options.page ?? 1 // Stays 0 if page is 0, becomes 1 only for null or undefined

??= is an operator that assigns only when the value is null or undefined.
||= and &&= work the same way.

JS
1
config.timeout ??= 3000 // Assigns 3000 only if there's no timeout

Optional chaining ?. returns undefined without an error if a value along the way is null or undefined.
When accessing nested objects, it keeps the program from stopping because an intermediate value is missing.

JS
1
2
3
4
5
6
7
const user = { profile: { name: 'Kim' } } user.profile.name // 'Kim' // user.account.name // Error, account is undefined, so the next access stops the program user.account?.name // undefined, moves on without an error user.profile?.address?.city // Safe access at every level

You can also use it for function calls and array access.

JS
1
2
user.getName?.() // Calls getName only if it exists list?.[0] // Accesses the first element only if list exists

Using ?. and ?? together, you can even handle a default value for missing data in a single line.

JS
1
const city = user.profile?.address?.city ?? 'No address'

# Functions

There are three ways to create a function: declarations, expressions, and arrow functions.
They behave the same, but arrow functions are short, so they're used especially often for callbacks.

JS
1
2
3
4
5
6
7
8
9
function add(a, b) { // Function declaration return a + b } const subtract = function (a, b) { // Function expression return a - b } const multiply = (a, b) => a * b // Arrow function

Here are the form and shorthand rules of arrow functions.

  • The form is (parameters) => returnValue.
  • If the body is a single expression, you can omit the curly braces and return (implicit return).
  • If there's only one parameter, you can omit the parentheses.
  • When implicitly returning an object, wrap it in parentheses so the curly braces aren't read as the function body.
JS
1
2
3
4
5
6
const double = n => n * 2 // Parentheses can be omitted for a single parameter const makeUser = name => ({ name }) // Wrap a returned object in parentheses const area = (w, h) => { // Use curly braces and return for a multi-line body const result = w * h return result }

You can give parameters default values.
If you don't pass an argument or pass undefined, the default value is used.

JS
1
2
3
4
const greet = (name = 'guest') => `Hello, ${name}` greet('Kim') // 'Hello, Kim' greet() // 'Hello, guest'

For a function with several options, take a single object instead of listing parameters.
Callers don't need to remember the order, and they can pass only the options they need.
The syntax for destructuring an object as soon as you receive it is covered in Destructuring assignment.

JS
1
2
3
4
5
6
function request({ url, method = 'GET', retry = 0 }) { // Takes an object and destructures it right away console.log(method, url, retry) } request({ url: '/api/users' }) // 'GET', '/api/users', 0 request({ url: '/api/users', method: 'POST' }) // 'POST', '/api/users', 0

Arrow functions don't have their own this; they use the this of the outer scope where they're declared.
If you need this in an object's method or inside a class, use a regular function or the method shorthand syntax.
How this works is covered in detail in JS Function Core Patterns.

# Callbacks and higher-order functions

A callback is a function that you pass as an argument to another function.
The function that receives it calls the callback at a time of its choosing.
A function that takes a function as an argument or returns a function is called a higher-order function; the array methods map and filter and registering event listeners are typical examples.

JS
1
2
3
4
5
6
7
8
9
10
11
12
const button = document.querySelector('button') button.addEventListener('click', () => { console.log('Clicked') // Runs on every click }) setTimeout(() => { console.log('Runs after 1 second') }, 1000) const nums = [1, 2, 3] nums.forEach(n => console.log(n)) // Runs for each element

When you pass a function, pass the function itself, not the result of calling it.
If you add parentheses, it runs immediately on the spot and its return value (usually undefined) is passed.

JS
1
2
3
4
5
6
function handleClick() { console.log('Clicked') } button.addEventListener('click', handleClick) // Passes the function itself button.addEventListener('click', handleClick()) // Passes the result of running it immediately (undefined), a mistake

If you want to pass a callback with preset arguments, wrap it in an arrow function.

JS
1
button.addEventListener('click', () => handleClick('save')) // Runs handleClick('save') at click time

# Destructuring assignment

Destructuring assignment is syntax that takes the values you need out of an object or array and assigns them to variables.
Objects are unpacked by property name, and arrays by order.

Destructuring assignment takes values out by name from objects and by order from arrays

JS
1
2
3
4
5
const user = { id: 1, name: 'Kim', age: 20 } const { name, age } = user // Extract only the values you need const { name: userName } = user // Under a different name, userName const { role = 'guest' } = user // Default value 'guest' if there's no value

You can also destructure nested objects level by level.
However, deeper nesting is hard to read, so use it only up to about two levels.

JS
1
2
3
const data = { user: { name: 'Kim', address: { city: 'Seoul' } } } const { user: { address: { city } } } = data // city is 'Seoul'

The pattern of destructuring right in a function's parameter list is used very often.
This is also how framework components receive props.

JS
1
2
3
4
5
function printUser({ name, age }) { console.log(`${name} (${age})`) } printUser(user) // 'Kim (20)'

Arrays are unpacked in index order.
Skip the positions you don't need with commas.

JS
1
2
const [first, second] = [10, 20] // first is 10, second is 20 const [, , third] = [1, 2, 3] // Skips the first two, third is 3

A function that returns several values bundles them in an array or object, and the caller destructures them.
Use an array if the order of the returned values is fixed, and an object if you want to tell them apart by name.

JS
1
2
3
4
5
6
7
8
9
10
function getSize(el) { return [el.offsetWidth, el.offsetHeight] // Told apart by order } const [width, height] = getSize(box) function parseNumber(text) { const value = Number(text) return { value, ok: !Number.isNaN(value) } // Told apart by name } const { value, ok } = parseNumber('42') // value is 42, ok is true

# Spread and rest

... works in two ways, depending on where it's used.
It's either spread, which expands values, or rest, which gathers the remaining values into one.

Spread expands one into many, and rest gathers many into one

Spread expands an object or array to copy or merge it.

JS
1
2
3
4
5
6
7
8
const a = [1, 2] const b = [3, 4] const merged = [...a, ...b] // [1, 2, 3, 4] const copy = [...a] // A copy separate from the original const base = { name: 'Kim', age: 20 } const updated = { ...base, age: 21 } // A new object with only age overwritten

It's also used to spread an array into a function's arguments.

JS
1
2
const nums = [5, 2, 8] Math.max(...nums) // Same as Math.max(5, 2, 8), 8

When merging objects, if property names overlap, the later value overwrites the earlier one.
This is used for the pattern of overriding default settings with user settings.

JS
1
2
3
const defaults = { theme: 'light', lang: 'ko' } const custom = { lang: 'en' } const settings = { ...defaults, ...custom } // { theme: 'light', lang: 'en' }

Rest gathers the remaining values into a single array or object.
It can be used only in the last position of a destructuring pattern.
When used in a function's parameter list, it's called rest parameters, and it gathers the passed arguments into an array.

JS
1
2
3
4
5
6
7
8
9
10
11
const [head, ...tail] = [1, 2, 3, 4] // head is 1, tail is [2, 3, 4] const { id, ...rest } = { id: 1, name: 'Kim', age: 20 } // id is 1, rest is { name: 'Kim', age: 20 } function sum(...numbers) { // Gathers the arguments into an array, numbers is [1, 2, 3] let total = 0 for (const n of numbers) total += n return total } sum(1, 2, 3) // 6

Rest properties are often used to make a copy of an object without certain properties.

JS
1
const { password, ...safeUser } = user // Only the rest, without password, goes into safeUser

# Array methods

You often handle data with array methods instead of loops.
They all take a callback function as an argument, and the callback receives the element, the index, and the array, in that order.

map changes elements while keeping their count, filter keeps only some, and reduce combines them into one

Method What it does Returns
map Transforms each element A new array of the same length
filter Keeps only the elements that meet a condition A new array
find The first element that meets a condition One element, or undefined if none
findIndex The position of the first element that meets a condition The index, or -1 if none
some Whether at least one element meets a condition true or false
every Whether all elements meet a condition true or false
includes Whether a specific value is present true or false
reduce Accumulates the elements into one The accumulated result
forEach Only runs an action for each element Nothing (undefined)
JS
1
2
3
4
5
6
7
8
9
10
11
12
const users = [ { name: 'Kim', age: 20, active: true }, { name: 'Lee', age: 17, active: false }, { name: 'Park', age: 25, active: true } ] const names = users.map(u => u.name) // ['Kim', 'Lee', 'Park'] const adults = users.filter(u => u.age >= 20) // Kim, Park const lee = users.find(u => u.name === 'Lee') // { name: 'Lee', ... } const hasActive = users.some(u => u.active) // true const allAdults = users.every(u => u.age >= 20) // false const totalAge = users.reduce((sum, u) => sum + u.age, 0) // 62

reduce takes a callback that receives the accumulator and the current element, plus the initial value of the accumulator.
Besides totals, it's also used to change the shape of data, such as turning an array into an object.

JS
1
2
3
4
const byName = users.reduce((acc, u) => { acc[u.name] = u // Converts to an object keyed by name return acc }, {}) // { Kim: {...}, Lee: {...}, Park: {...} }

Methods that return a new array can be called one after another with a dot (.).
This is called method chaining.

JS
1
2
3
const activeNames = users .filter(u => u.active) .map(u => u.name) // ['Kim', 'Park']

Sort with sort or toSorted.
sort sorts the original array in place, while toSorted leaves the original as is and returns a new sorted array.
Both convert elements to strings to compare them by default, so you need to pass a compare function when sorting numbers.
If the compare function returns a negative number, the first argument comes first.

JS
1
2
3
4
5
const nums = [10, 9, 1] nums.toSorted() // [1, 10, 9], sorted as strings nums.toSorted((a, b) => a - b) // [1, 9, 10], ascending users.toSorted((a, b) => b.age - a.age) // Descending by age

toSorted, toReversed, toSpliced, and with are methods added in ES2023, and you can use them in modern browsers and Node.js 20 or later.

When you need a loop, use for...of.
Unlike forEach, you can stop it midway with break, and you can use await inside it.

JS
1
2
3
4
5
6
7
8
for (const user of users) { if (!user.active) continue console.log(user.name) } for (const [index, user] of users.entries()) { console.log(index, user.name) // When you need the index }

# Immutability and copying

Primitive values are immutable.
Even when it looks like you're changing a value, the existing value doesn't change; the variable just points to a new value.

Objects and arrays are mutable.
You can change their internal properties or elements while the reference stays the same.
const only prevents reassignment; it doesn't prevent internal changes.

JS
1
2
3
const user = { name: 'Kim' } user.name = 'Lee' // Allowed, changes an internal value // user = {} // Error, reassignment

Array methods are divided into those that change the original and those that return a new array.

Category Methods
Change the original push, pop, shift, unshift, splice, sort, reverse
Return a new array map, filter, slice, concat, toSorted, toReversed, toSpliced, with

sort and reverse cause mistakes especially often because they change the original in place instead of returning a new sorted array.
To keep the original, sort a copy or use toSorted, which returns a new array.

JS
1
2
3
4
5
const nums = [3, 1, 2] nums.sort() // The original changes, nums is [1, 2, 3] const sorted = [...nums].sort() // Sorts only the copy const sorted2 = nums.toSorted() // Returns a new array, keeps the original

Here's how to change values without changing the original.
Copy the object or array, then create a new value with only the needed parts changed.

JS
1
2
3
4
5
6
7
const user = { name: 'Kim', age: 20 } const updated = { ...user, age: 21 } // Copy, then change only part of it const list = [1, 2, 3] const added = [...list, 4] // Add, [1, 2, 3, 4] const removed = list.filter(n => n !== 2) // Remove, [1, 3] const doubled = list.map(n => n * 2) // Change, [2, 4, 6]

Working this way makes it easy to track when values change and reduces bugs caused by accidentally changing a shared reference.
In tools like React that detect changes by whether the reference changed, changing the original directly causes a problem where the screen doesn't update even though the value changed.

The spread operator and Object.assign make a shallow copy.
They copy only the first level of properties, and the objects inside still share the same references.

A shallow copy creates only a new outer object and shares the inner objects, while a deep copy creates new objects all the way down

JS
1
2
3
4
5
const origin = { info: { age: 20 } } const copy = { ...origin } copy.info.age = 30 console.log(origin.info.age) // 30, the inner object is shared

To fully copy nested structures, use a deep copy.
structuredClone creates new copies of all nested objects and arrays.

JS
1
2
3
4
const deep = structuredClone(origin) // Copies even the inner objects separately deep.info.age = 40 console.log(origin.info.age) // 30, the original stays the same

To change a nested value without changing the original, copy each level you want to change with the spread operator.

JS
1
2
3
4
const next = { ...origin, info: { ...origin.info, age: 40 } // Creates a new inner object too }

# Working with objects

If a property name and a variable name are the same, write it only once (shorthand property).
Methods can also be written in short form without the function keyword (method shorthand).

JS
1
2
3
4
5
6
7
8
9
10
const name = 'Kim' const age = 20 const user = { name, // name: name age, greet() { // greet: function () { ... } return `Hello, I'm ${this.name}` } }

If you wrap a property name in square brackets, the variable's value is used as the property name (computed property name).
It's used when the property name is decided at runtime, such as updating state by the name of an input field.

JS
1
2
3
4
5
6
7
const key = 'score' const obj = { [key]: 100 } // { score: 100 } const form = { email: '', password: '' } function update(field, value) { return { ...form, [field]: value } // Updates any field with the same function }

Objects don't have array methods like map and filter, so convert the properties to an array before working with them.
You can create an array with Object.entries, process it, and then turn it back into an object with Object.fromEntries.

JS
1
2
3
4
5
6
7
8
9
10
11
12
13
const scores = { kim: 90, lee: 85 } Object.keys(scores) // ['kim', 'lee'] Object.values(scores) // [90, 85] Object.entries(scores) // [['kim', 90], ['lee', 85]] for (const [name, score] of Object.entries(scores)) { console.log(`${name}: ${score}`) // 'kim: 90', 'lee: 85' } const passed = Object.fromEntries( Object.entries(scores).filter(([, score]) => score >= 90) ) // { kim: 90 }

You can't send an object to a server or store it as is, so you convert it to a string to exchange it.
JSON (JavaScript Object Notation) is the text format used for this, and you convert with JSON.stringify and JSON.parse.

JS
1
2
3
4
5
const json = JSON.stringify({ name: 'Kim', age: 20 }) // '{"name":"Kim","age":20}' const user = JSON.parse(json) // { name: 'Kim', age: 20 } localStorage.setItem('user', JSON.stringify(user)) // To a string when saving const saved = JSON.parse(localStorage.getItem('user')) // Back to an object when reading

JSON.stringify drops functions and undefined properties, and turns Date objects into strings.
Note that running JSON.parse after the conversion doesn't bring back the original types.

Set is a collection of unique values, and Map is a key-value store that can use any type as a key.
Set is most often used to remove duplicates from an array.

JS
1
2
3
4
5
6
7
8
9
10
11
12
const tags = ['js', 'ts', 'js'] const unique = [...new Set(tags)] // ['js', 'ts'] const set = new Set([1, 2]) set.add(3) set.has(2) // true set.size // 3 const map = new Map() map.set('kim', { age: 20 }) map.set(button, 'clicked') // Objects can be used as keys too map.get('kim') // { age: 20 }

# Asynchronous programming

# Synchronous and asynchronous

Synchronous code runs line by line from top to bottom, and it moves on only after the previous task finishes.
Asynchronous code starts a time-consuming task, such as a network request or a timer, and runs the next code first without waiting for the task to finish.
You get the result of the task later through a callback or a Promise.

Synchronous code moves on only after the previous task finishes, while asynchronous code runs the next code first while waiting

JS
1
2
3
4
console.log(1) setTimeout(() => console.log(2), 0) // Runs later, even with a 0-second delay console.log(3) // Output order: 1, 3, 2

JavaScript has only a single flow that runs code line by line, so if it waited synchronously for a time-consuming task, the screen would freeze in the meantime.
That's why network requests, file reads, and timers in the browser all work asynchronously.

# Promise

A Promise is an object that promises to hold the result of an asynchronous task later.
It has three states, and once it settles from pending to fulfilled or rejected, it never changes again.

A Promise changes only once, from pending to fulfilled or rejected

  • Pending: Not finished yet.
  • Fulfilled: Succeeded, and a result value exists.
  • Rejected: Failed, and an error exists.

Receive the result with then, the error with catch, and any cleanup that runs regardless of success with finally.
In the examples below, assume that fetchUser and fetchPosts are functions that get data from a server and return a Promise.

JS
1
2
3
4
fetchUser() .then(user => console.log(user.name)) // Runs when fulfilled .catch(error => console.log(error.message)) // Runs when rejected .finally(() => console.log('Request finished')) // Always runs

then returns a new Promise, so you can chain calls one after another.
The value returned from a callback is passed to the next then, and if an error occurs in the middle of the chain, it jumps to the nearest catch.

JS
1
2
3
4
fetchUser() .then(user => fetchPosts(user.id)) // Uses the previous result for the next task .then(posts => console.log(posts)) .catch(error => console.log(error)) // Comes here no matter which step fails

To create one yourself, pass new Promise a function that receives resolve for success and reject for failure.
This is mainly used to wrap a callback-based API in a Promise.

JS
1
2
3
4
5
function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)) } delay(1000).then(() => console.log('Runs after 1 second'))

# async/await

async and await are syntax for writing Promise code that reads from top to bottom like synchronous code.

  • An async function always returns a Promise.
  • await waits until a Promise is fulfilled and then gives back its result value.
  • Use await inside an async function. You can also use it at the top level of a module.
  • await pauses only that function's execution, not the whole program.
JS
1
2
3
4
5
6
7
async function load() { const user = await fetchUser() const posts = await fetchPosts(user.id) // Runs after user is received return posts // Returned wrapped in a Promise } load().then(posts => console.log(posts))

If a Promise is rejected, await throws the error right there.
Handle it by wrapping the code in try...catch, and put code that should run regardless of success in finally.

JS
1
2
3
4
5
6
7
8
9
10
11
async function load() { try { const user = await fetchUser() return user } catch (error) { console.log('Failed to load:', error.message) return null } finally { setLoading(false) // Runs whether it succeeds or fails } }

# fetch

fetch is a network request function built into browsers and Node.js.
It returns a Promise that holds the response object, and json(), which reads the body as JSON, also returns a Promise, so you await twice.

JS
1
2
3
4
5
6
7
async function getUser(id) { const response = await fetch(`/api/users/${id}`) if (!response.ok) { // HTTP errors like 404 and 500 aren't rejected, so check yourself throw new Error(`Request failed: ${response.status}`) } return await response.json() // Converts the body to an object }

fetch is rejected only when the network itself fails; it's fulfilled even if the server sends an error status code.
That's why you need code that checks response.ok.

To send data, pass method, headers, and body as options.
Convert objects to strings with JSON.stringify before sending them.

JS
1
2
3
4
5
const response = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Kim', age: 20 }) })

# Running multiple tasks at once

If you await unrelated tasks one after another, the next task can't start until the previous one finishes.
If you pass them to Promise.all as an array, all the tasks start at the same time, and you get the results as an array when they're all done.

Waiting in turn adds up the times, while starting them together with Promise.all takes only as long as the longest task

JS
1
2
const user = await fetchUser() // 1 second const config = await fetchConfig() // 1 second, starts after the previous task finishes
Waiting in turn, 2 seconds in total
JS
1
const [user, config] = await Promise.all([fetchUser(), fetchConfig()])
Starting together, 1 second in total

Methods that handle multiple Promises together differ in how they handle failures.

Method Behavior
Promise.all An array of results if all succeed, rejected immediately if any one fails
Promise.allSettled Gets every result, whether it succeeded or failed
Promise.race The result of whichever finishes first, success or failure
Promise.any The result of whichever succeeds first
JS
1
2
3
4
5
6
const results = await Promise.allSettled([fetchUser(), fetchConfig()]) for (const result of results) { if (result.status === 'fulfilled') console.log(result.value) // The successful result else console.log(result.reason) // The reason for failure }

If you use await inside forEach, it moves on to the next element without waiting.
If you need to wait in order, use for...of; if they can run at the same time, create an array of Promises with map and pass it to Promise.all.

JS
1
2
3
4
5
for (const id of ids) { await deleteUser(id) // One at a time, in order } await Promise.all(ids.map(id => deleteUser(id))) // All at the same time

# Modules

Modules are a structure that splits code into multiple files and connects them with import and export.
Each file is a module, and each module has its own scope, so variables that aren't exported can't be accessed from outside.

Only what's exported can be imported into other files

There are two ways to export.

  • Named export: Export several things, each with a name. When importing, receive them with the same names in curly braces.
  • Default export: Export only one per file. When importing, you choose the name freely.
/math.js
JS
1
2
3
4
5
6
export const add = (a, b) => a + b // Named export export const PI = 3.14 export default function multiply(a, b) { // Default export return a * b }
/main.js
JS
1
2
3
import multiply, { add, PI } from './math.js' // Default and named together import { add as plus } from './math.js' // Rename with as import * as math from './math.js' // Bundle everything and use it as math.add

Here are the rules for writing paths.

  • A path starting with ./ or ../ points to a file you wrote.
  • Just a name points to an installed package (node_modules).
JS
1
2
import { format } from 'date-fns' // Installed package import { add } from './math.js' // File you wrote

To use modules directly in the browser, load them with <script type="module">.
Projects that use a build tool like Vite can use them without extra setup, and the build process combines multiple modules into a single file.

Calling import() like a function loads a module when you need it.
It's used to load heavy modules later to reduce the initial loading size.

JS
1
2
3
4
button.addEventListener('click', async () => { const { default: Chart } = await import('./chart.js') // Loaded at click time new Chart() })

# Classes

A class is a template for creating many objects with the same structure, and an object created from a class is called an instance.
Create an instance with new, set the initial state in the constructor, and access the instance itself with this in methods.

JS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Counter { count = 0 // Field constructor(start) { this.count = start } increase() { // Method this.count += 1 return this.count } } const counter = new Counter(10) counter.increase() // 11 counter instanceof Counter // true, checks whether it's an instance created from Counter

Extend an existing class with extends.
You'll extend built-in or library classes, or create instances of them, more often than you'll design classes yourself.
Classes that extend Error are often used to tell kinds of errors apart.

JS
1
2
3
4
5
6
7
8
9
10
11
12
class HttpError extends Error { constructor(status, message) { super(message) // Calls the parent class's constructor this.status = status } } try { throw new HttpError(404, 'Not found') } catch (error) { if (error instanceof HttpError) console.log(error.status) // 404 }

Prototypes, getters and setters, static methods, and more are covered in JS Class Core Patterns.

# TypeScript

TypeScript is JavaScript with types added.
All JavaScript code is valid TypeScript code, so you keep using the syntax we've covered so far and only need to learn the type syntax on top of it.

TypeScript code goes through type checking and is then converted to JavaScript with the types removed

  • You can catch wrong values and typos before running the code (in the editor or at build time).
  • Autocompletion and code navigation become accurate.
  • Types disappear when the code is converted to JavaScript, so they don't affect runtime speed.
TS
1
2
let count: number = 0 count = '3' // Shown as an error in the editor right away
JS
1
2
let count = 0 count = '3' // The types are gone and only the code remains
Converted JavaScript

Use the .ts file extension, and .tsx for files that use JSX, like in React.
Projects created with Vite can use .ts files without extra setup; for other development environments, see TypeScript at a Glance.

Types are only checked before the code runs; values aren't checked while the code runs.
Values coming from outside, such as server responses, may differ from their types even if you write types for them; this is covered at the end in Types for external data.

# Type annotations and inference

A type annotation states that "this value is of this type" for a variable or function.
Put a colon (:) after the name and write the type.

TS
1
2
3
let title: string = 'Hello' let age: number = 20 let isOpen: boolean = false

Basic types are written in lowercase.

Type Values
string Strings
number Both integers and decimals
boolean true, false
null, undefined Each allows only that one value
Type[] An array of that type. number[], string[]
{ property: Type } An object. { id: number; name: string }
TS
1
2
3
4
5
const ids: number[] = [1, 2, 3] const names: string[] = ['Kim', 'Lee'] const user: { id: number; name: string } = { id: 1, name: 'Kim' } const users: { id: number; name: string }[] = [] // An array of objects

For functions, write types for the parameters and the return value.
If there's no return value, use void.

TS
1
2
3
4
5
6
7
function add(a: number, b: number): number { return a + b } const greet = (name: string): void => { console.log(`Hi, ${name}`) }

If there's an initial value, the type is inferred automatically even if you don't annotate it (type inference).
In the editor, hover over a variable to see its inferred type.

TS
1
2
3
4
5
6
7
let count = 0 // Inferred as number let text = 'hi' // Inferred as string const user = { id: 1, name: 'Kim' } // Inferred as { id: number; name: string } function double(x: number) { return x * 2 // The return type is inferred as number }

It's practical to leave inference to TypeScript where it works and to annotate types yourself only where inference doesn't work or is ambiguous.
Always annotate function parameters, since there's nothing to infer them from, and also annotate when the initial value alone can't tell you the type of values that come later, as with an empty array.

TS
1
2
const items: string[] = [] // Annotate an empty array, because its element type is unknown items.push('a') // Only strings can be added

# Union and literal types

A union type allows one of several types with |.
A literal type allows only specific values.
Combine the two to express a list of allowed values as a type; this is very often used to define string status values or options.

TS
1
2
3
4
5
6
7
8
9
let value: string | number // A string or a number value = 'hello' value = 42 let status: 'idle' | 'loading' | 'done' // Allows only these three values status = 'loading' // status = 'stop' // Error let user: User | null = null // A value that can be empty; User is an object type defined in the next section

Giving a type a name with the type keyword is called a type alias, and you can reuse it in many places.

TS
1
2
3
4
5
6
type Status = 'idle' | 'loading' | 'done' type Size = 'sm' | 'md' | 'lg' function setSize(size: Size) {} setSize('md') // setSize('xl') // Error

To derive a type from an array that lists values, fix the values as literal types with a const assertion, as const.
You can then use the same list both as values and as a type.

TS
1
2
3
4
const SIZES = ['sm', 'md', 'lg'] as const // readonly ['sm', 'md', 'lg'] type Size = (typeof SIZES)[number] // 'sm' | 'md' | 'lg' SIZES.map(size => `<option>${size}</option>`) // Also used as values

typeof SIZES is the type of the array value, and adding [number] after it takes out the type of the array's elements.

TypeScript also has enum, but in web development, literal unions like the one above are used more often.
For the features of enum and how it differs, see TypeScript Enum Types in Depth.

# interface and type

These are the two ways to define the shape of an object.
interface defines an object structure and can inherit from and extend other interfaces with extends.

TS
1
2
3
4
5
6
7
8
9
10
interface User { id: number name: string } interface Admin extends User { // Inherits User's properties and extends them role: string } const admin: Admin = { id: 1, name: 'Kim', role: 'owner' }

type, which creates type aliases, can name any type, not just objects: unions, functions, primitive types, and more.
To combine object types, use an intersection type, &.

TS
1
2
3
type ID = number | string // Naming a union type Handler = (event: MouseEvent) => void // Function type type Admin = User & { role: string } // Combining two object types

Define object types with interface, and use type for types that interface can't express, such as unions, and for function types.
The official docs also advise using interface until you need features of type.
With interface, declaring the same name several times merges them into one (declaration merging), which is also useful for adding properties to types provided by libraries.
This is covered in detail in Should I use interface or type in TypeScript?.

Adding ? after a property name makes it an optional property, which may or may not be present.
Its type automatically becomes Type | undefined, so you need to check whether the value exists before using it.
Adding readonly means it can't be changed once it's set.

TS
1
2
3
4
5
6
7
8
9
interface Props { title: string subtitle?: string // Optional, string | undefined readonly id: number // Readonly } const props: Props = { title: 'Title', id: 1 } props.subtitle?.toUpperCase() // Accessed with ?. because it may be missing // props.id = 2 // Error, readonly

Function types are written in a form similar to arrow functions.
They're used when passing event handlers or callbacks as props.

TS
1
2
3
4
5
6
7
8
interface ButtonProps { label: string onClick: (event: MouseEvent) => void // Function type onChange?: (value: string) => void // Optional property } type Calculator = (a: number, b: number) => number const sum: Calculator = (a, b) => a + b // Parameter types are inferred from Calculator

When you import only types, use import type.
That import is removed when the code is converted to JavaScript, so importing a types-only file doesn't leave unnecessary code behind.

TS
1
2
import type { User } from './types' // Imports only types import { fetchUser, type Role } from './api' // To import values and types together, put type before each type

# Type guards

A value that can be one of several types, like a union type, only allows operations that are possible for all of those types.
A type guard is a condition that checks a value's type, such as typeof or instanceof; inside a block that passes the guard, the type is narrowed down to one, so you can use operations that only that type supports.

Inside a block checked by a condition, a union type is narrowed down to a single type

Type guard Use
typeof value === 'string' Distinguishes primitive types
Array.isArray(value) Checks whether it's an array
'property' in value Checks whether an object has a specific property
value instanceof Class Checks whether it's an instance of a specific class
value === null, !value Excludes null and undefined
TS
1
2
3
4
5
6
function format(value: string | number) { if (typeof value === 'string') { return value.toUpperCase() // Inside this block, it's a string } return value.toFixed(2) // Here, it's a number }

For values that can be null, a common pattern is to check first and return early.
In the code after the check, the type is treated as one with null excluded.

TS
1
2
3
4
function getName(user: User | null) { if (!user) return 'No name' // Return first if it's null return user.name // Below this line, it's definitely User }

Object unions are told apart by the literal value of a shared property.
You express results with different shapes, like success and failure, as a single type, and checking the discriminant property narrows the types of the other properties too.
This is called a discriminated union, and it's often used for API responses and state.
It's covered in detail in TypeScript at a Glance.

TS
1
2
3
4
5
6
7
8
9
10
11
type Result = | { ok: true; data: User } | { ok: false; error: string } function handle(result: Result) { if (result.ok) { console.log(result.data.name) // Narrowed to the shape where ok is true, so data can be used } else { console.log(result.error) // Only error exists here } }

# any, unknown, never

These three types mean "anything", "unknown", and "nothing", respectively.

unknown holds every value, never holds no value at all, and any turns off checking

  • any: Doesn't check types. It allows anything, but you lose the benefits of TypeScript, so avoid it.
  • unknown: A value whose type isn't known. Unlike any, it forces you to check the type before using it, so it's safe.
  • never: Can't be any value. Used as the return type of functions that always throw an error or never finish.
TS
1
2
3
4
5
6
7
8
9
10
11
12
let a: any = JSON.parse(text) a.foo.bar // Not checked, may throw an error at runtime let u: unknown = JSON.parse(text) // u.name // Error, can't be used before checking if (typeof u === 'object' && u !== null && 'name' in u) { console.log(u.name) // Used after narrowing } function fail(message: string): never { throw new Error(message) // Never returns a value normally }

Use unknown instead of any when receiving values from outside.
The error in catch is also unknown because any value can be thrown, so check that it's an Error instance before reading message.

TS
1
2
3
4
5
6
try { await fetchUser() } catch (error) { // If the strict option in tsconfig is on, error is unknown const message = error instanceof Error ? error.message : String(error) console.log(message) }

# Type assertions

A type assertion is syntax that tells TypeScript, "I know this value's type better."
It doesn't change or check the actual value, and a wrong assertion leads to runtime errors, so use it only when you really need it.

as asserts the type of a value.
It's mainly used for values whose specific type can't be inferred, such as DOM elements.

TS
1
2
3
4
5
// The return type of getElementById is HTMLElement | null, which has no value property const input = document.getElementById('email') as HTMLInputElement input.value = 'test@mail.com' // Asserted as HTMLInputElement, so value can be used const data = JSON.parse(text) as User // Treats the parsed result as User without checking the actual value

! is the non-null assertion operator, which asserts that a value isn't null or undefined.

TS
1
2
const el = document.querySelector('input') // Type: HTMLInputElement | null el!.focus() // Asserts it isn't null; use only when you're sure

Whenever possible, check with a type guard first and use assertions as a last resort.

TS
1
2
const el = document.querySelector('input') if (el) el.focus() // Used after checking, safe even when it's missing

# Generics

Generics are syntax that leaves a slot for a type to be decided later and fills it in when used.
Declare a type parameter inside angle brackets, like <T>, and pass the type when calling or let it be inferred from the arguments.
It's easy to understand if you think of it as passing a type the way you pass an argument to a function.

TS
1
2
3
4
5
6
function first<T>(arr: T[]): T { return arr[0] } const n = first<number>([1, 2, 3]) // T is number, and the return is number too const s = first(['a', 'b']) // T is inferred as string from the argument

You'll write types in the < > slots that libraries require far more often than you'll create generics yourself.
Promise<User> means "a Promise that fulfills with a User", and useState<User | null> means "state that holds a User or null".

TS
1
2
3
4
async function getUser(id: number): Promise<User> { // A Promise that holds a User const response = await fetch(`/api/users/${id}`) return response.json() }
TS
1
const [user, setUser] = useState<User | null>(null) // Pass the type the state will hold
React
TS
1
const user = ref<User | null>(null)
Vue

Generics are also used for object types.
They're used when the outer structure is the same and only the data inside differs, as with API responses.

TS
1
2
3
4
5
6
7
interface ApiResponse<T> { data: T status: number } const userResponse: ApiResponse<User> = { data: { id: 1, name: 'Kim' }, status: 200 } const listResponse: ApiResponse<User[]> = { data: [], status: 200 }

You can put a constraint on a type parameter with extends.
It means "any type is fine, but it must have at least this structure."

TS
1
2
3
4
5
6
function getId<T extends { id: number }>(item: T) { return item.id // Can be used, since id is guaranteed to exist } getId({ id: 1, name: 'Kim' }) // getId({ name: 'Kim' }) // Error, no id

# Utility types

Utility types are built-in generic types that create new types from existing ones.
Instead of defining similar types many times, you create variations from a single type.

Utility type Result
Partial<T> Makes all properties optional
Required<T> Makes all properties required
Pick<T, K> Picks only the specified properties
Omit<T, K> Excludes only the specified properties
Record<K, V> An object with K as keys and V as values
ReturnType<F> The return type of function F
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
interface User { id: number name: string email: string } type UserUpdate = Partial<User> // { id?: number; name?: string; email?: string } type UserPreview = Pick<User, 'id' | 'name'> // { id: number; name: string } type UserForm = Omit<User, 'id'> // Everything except id, used for a create form type UsersById = Record<number, User> // An object that holds a User for each number key function update(id: number, changes: UserUpdate) {} update(1, { name: 'Lee' }) // Pass only the properties to change

The full list and how they work are covered in TypeScript Utility Types in Depth.

# Types for external data

Types are checked only before the code runs and disappear at runtime, so data that comes from outside, such as server responses or values read from localStorage, isn't subject to type checking.
Asserting with as User or writing Promise<User> is only a declaration that "I'll trust it's so"; if the actual value is different, you run into problems at runtime.

TS
1
2
const user = (await response.json()) as User // Not checked even if the server sends a different shape user.name.toUpperCase() // Runtime error if there's no name

Where you really need the value to be right, receive it as unknown and check the actual value before using it.
If you use a type predicate, written in the return type like value is User, the function becomes a type guard, and when it returns true, the type is narrowed on the calling side too.

TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function isUser(value: unknown): value is User { // Narrows value to User if true return ( typeof value === 'object' && value !== null && 'id' in value && typeof value.id === 'number' && 'name' in value && typeof value.name === 'string' ) } const data: unknown = await response.json() if (isUser(data)) { console.log(data.name) // Used as User after it's checked }

When there are many properties to check, use a library that validates with a schema instead of writing the checks yourself.
Zod Essentials covers how to define both the type and the validation with a single schema.

# Next steps

Once you're fairly comfortable with the syntax, move on to the framework you'll use.
Each framework writes components differently, but the syntax covered here is used as is everywhere.

To understand each piece of syntax in more depth, see the posts below.