Skip to content

Syntax and Types

JavaScript has exactly eight types: seven primitives and object. Everything you will ever hold in a variable is one of them. This page covers what each one is, how to declare variables, and the parsing rules that occasionally bite.

Type Example values typeof result
number 42, -0.5, NaN, Infinity 'number'
bigint 9007199254740993n 'bigint'
string 'hi', `x=${x}` 'string'
boolean true, false 'boolean'
undefined undefined 'undefined'
null null 'object' ← historical bug
symbol Symbol('id') 'symbol'
object {}, [], function () {}, new Date() 'object' / 'function'

The first seven are primitives: immutable values compared by value. object covers everything else — plain objects, arrays, functions, dates, regexes, Map, Set, promises — all compared by reference.

typeof 42; // => 'number'
typeof 'hi'; // => 'string'
typeof true; // => 'boolean'
typeof undefined; // => 'undefined'
typeof Symbol(); // => 'symbol'
typeof 10n; // => 'bigint'
typeof {}; // => 'object'
typeof []; // => 'object' (arrays are objects)
typeof function () {} // => 'function' (functions are objects, but get their own tag)
typeof null; // => 'object' (a bug from 1995, never fixed)

typeof has one genuinely useful property: it does not throw on an undeclared identifier.

typeof neverDeclared; // => 'undefined' (no ReferenceError)
neverDeclared; // ReferenceError: neverDeclared is not defined

typeof is too coarse for objects. Use these:

Array.isArray([]); // => true
value instanceof Date; // => true for dates (same realm only)
Object.prototype.toString.call(new Map()); // => '[object Map]'

Every JavaScript number is a 64-bit IEEE-754 double-precision float. There is no separate integer type. Integers are exact up to 2^53 − 1; above that, precision is lost.

Number.MAX_SAFE_INTEGER; // => 9007199254740991 (2**53 - 1)
Number.MAX_SAFE_INTEGER + 1; // => 9007199254740992
Number.MAX_SAFE_INTEGER + 2; // => 9007199254740992 ← wrong, silently
Number.isSafeInteger(2 ** 53); // => false

Because the format is binary, decimal fractions like 0.1 cannot be represented exactly — the same reason 1/3 cannot be written exactly in decimal.

0.1 + 0.2; // => 0.30000000000000004
0.1 + 0.2 === 0.3; // => false
(0.1 + 0.2).toFixed(2); // => '0.30' (a string)

Compare floats with a tolerance, or work in integers (store money as cents):

const nearlyEqual = (a, b, eps = Number.EPSILON) => Math.abs(a - b) < eps;
nearlyEqual(0.1 + 0.2, 0.3); // => true
255; // decimal
0xff; // hex => 255
0b1111_1111; // binary with numeric separators => 255
0o377; // octal => 255
1e3; // exponent => 1000
1_000_000; // separators are ignored by the parser
1 / 0; // => Infinity
-1 / 0; // => -Infinity
0 / 0; // => NaN
Math.sqrt(-1); // => NaN

NaN (“not a number”) is the result of a failed numeric operation. It is the only value in JavaScript that is not equal to itself.

NaN === NaN; // => false
Number.isNaN(NaN); // => true ← use this
isNaN('hello'); // => true ← global isNaN coerces first; avoid it
Number.isNaN('hello'); // => false (it is a string, not NaN)

There are also two zeros, 0 and -0. They compare equal with === but differ under Object.is:

0 === -0; // => true
Object.is(0, -0); // => false
Object.is(NaN, NaN); // => true
Number('42'); // => 42
Number('42px'); // => NaN (strict: whole string must be numeric)
Number(''); // => 0 (surprising, but defined)
parseInt('42px', 10); // => 42 (lenient: reads a prefix)
parseFloat('3.14 is pi'); // => 3.14
+'42'; // => 42 (unary plus, same as Number())

BigInt (ES2020) is an arbitrary-precision integer type for values beyond the safe-integer range — IDs from databases, cryptographic values, high-precision counters.

Create one with an n suffix or BigInt():

const big = 9007199254740993n;
const also = BigInt('9007199254740993');
big + 1n; // => 9007199254740994n (exact)
2n ** 64n; // => 18446744073709551616n

BigInts are integers only, and they cannot be mixed with numbers in arithmetic:

1n + 1; // TypeError: Cannot mix BigInt and other types
1n + BigInt(1); // => 2n
Number(9n) + 1; // => 10 (explicit conversion is fine)
7n / 2n; // => 3n (truncates toward zero — no fractions)

Comparison across types does work, because == and < are defined for it:

1n == 1; // => true (loose equality compares mathematical values)
1n === 1; // => false (different types)
2n > 1; // => true

Strings are immutable sequences of UTF-16 code units. Single and double quotes are identical in meaning; pick one and be consistent.

const a = 'single';
const b = "double";
const c = 'it\'s escaped';
const d = "it's easier";

Strings never change in place — every “modifying” method returns a new string.

let s = 'hello';
s.toUpperCase(); // => 'HELLO'
s; // => 'hello' (unchanged)
s[0] = 'H'; // ignored in sloppy mode; TypeError in strict mode
s; // => 'hello'

Backticks give interpolation, multi-line strings, and no escaping of quotes.

const name = 'Ada';
const age = 36;
const msg = `${name} is ${age} years old.
Next year: ${age + 1}.`;
Ada is 36 years old.
Next year: 37.

Any expression works inside ${} — function calls, ternaries, nested templates. Newlines and indentation inside backticks are preserved literally, which matters when generating output.

Tagged templates call a function with the literal parts and the interpolated values. This is how libraries build safe SQL or styled components.

function highlight(strings, ...values) {
return strings.reduce((out, str, i) =>
out + str + (i < values.length ? `[${values[i]}]` : ''), '');
}
highlight`Hi ${name}, you are ${age}`; // => 'Hi [Ada], you are [36]'
' padded '.trim(); // => 'padded'
'a-b-c'.split('-'); // => ['a', 'b', 'c']
'abc'.includes('b'); // => true
'abc'.startsWith('a'); // => true
'abc'.at(-1); // => 'c' (negative index; ES2022)
'abc'.slice(1); // => 'bc'
'5'.padStart(3, '0'); // => '005'
'ab'.repeat(3); // => 'ababab'
'a-b'.replaceAll('-', '+'); // => 'a+b' (ES2021)
'Ada'.localeCompare('Bob'); // => -1 (locale-aware ordering)

.length counts UTF-16 code units, not characters. Emoji and many non-Latin scripts use two units (a surrogate pair).

'café'.length; // => 4
'👋'.length; // => 2
[...'👋'].length; // => 1 (spread iterates by code point)

Use spread or for..of when you need real characters. For grapheme clusters (like flags or family emoji), use Intl.Segmenter.

Only true and false. Conversion follows the truthy/falsy rules covered in operators and control flow; the eight falsy values are false, 0, -0, 0n, '', null, undefined, and NaN.

Boolean(''); // => false
Boolean('0'); // => true (non-empty string)
Boolean([]); // => true (every object is truthy)
!!'text'; // => true (double negation, an idiomatic cast)

Both mean “no value”, and the difference is intent:

  • undefined — the engine’s absence. A declared-but-unassigned variable, a missing parameter, a missing property, or a function with no return.
  • null — the programmer’s absence. You deliberately set it to say “empty on purpose”.
let x;
x; // => undefined
({}).missing; // => undefined
(function () {})(); // => undefined (no return)
let selected = null; // "nothing is selected right now"

They are loosely equal to each other and to nothing else:

null == undefined; // => true
null === undefined; // => false
null == 0; // => false (null does not coerce to a number here)

Defaults treat them differently — a parameter default fires on undefined only:

function greet(name = 'friend') { return `Hi ${name}`; }
greet(undefined); // => 'Hi friend'
greet(null); // => 'Hi null' ← null is a real value

A symbol is a unique, immutable primitive used as a property key that can never collide with another key. Created with Symbol(description); the description is only a debugging label.

const a = Symbol('id');
const b = Symbol('id');
a === b; // => false (always unique)
a.description; // => 'id'

Symbol-keyed properties are skipped by Object.keys, JSON.stringify, and for..in, which makes them useful for metadata that should not leak into serialization.

const SECRET = Symbol('secret');
const user = { name: 'Ada', [SECRET]: 'hidden' };
Object.keys(user); // => ['name']
JSON.stringify(user); // => '{"name":"Ada"}'
user[SECRET]; // => 'hidden'
Object.getOwnPropertySymbols(user); // => [Symbol(secret)]

Well-known symbols let you hook into language behavior. The most useful is Symbol.iterator, which makes an object work with for..of and spread — see arrays and iteration.

const range = {
from: 1, to: 3,
[Symbol.iterator]() {
let n = this.from, last = this.to;
return { next: () => (n <= last ? { value: n++, done: false } : { value: undefined, done: true }) };
},
};
[...range]; // => [1, 2, 3]

Symbol.for(key) uses a global registry, so the same key returns the same symbol across your whole program (and across realms like iframes):

Symbol.for('app.id') === Symbol.for('app.id'); // => true

Three keywords. Use const by default, let when you must reassign, and var essentially never.

const MAX = 100; // cannot be reassigned
let count = 0; // can be reassigned
count += 1;
var legacy = 'x'; // function-scoped, hoisted — avoid
var let const
Scope function block block
Hoisted yes, initialized to undefined yes, but in the TDZ yes, but in the TDZ
Redeclarable in same scope yes no no
Reassignable yes yes no
Creates a global object property at top level yes no no

const freezes the binding, not the value. A const object can still be mutated.

const config = { debug: false };
config.debug = true; // fine — the object changed, the binding did not
config = {}; // TypeError: Assignment to constant variable

To make the value itself unchangeable, use Object.freeze — see objects and prototypes.

A block is anything between { } — an if body, a loop body, or a bare block. let and const are visible only inside it; var leaks to the enclosing function.

function demo() {
if (true) {
var v = 'var';
let l = 'let';
}
console.log(v); // => 'var'
console.log(l); // ReferenceError: l is not defined
}

This is the classic loop bug. With var, the loop has one shared binding; with let, each iteration gets a fresh one:

for (var i = 0; i < 3; i++) setTimeout(() => console.log(i), 0);
// => 3, 3, 3
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i), 0);
// => 0, 1, 2

Hoisting means declarations are processed before any code in the scope runs. What differs is what the name holds before its line executes.

  • var declarations are hoisted and initialized to undefined.
  • Function declarations are hoisted completely — you can call them before they appear.
  • let, const, and class are hoisted but left uninitialized. Touching them before their declaration throws. That window is the temporal dead zone (TDZ).
console.log(hoistedVar); // => undefined (declared, not yet assigned)
var hoistedVar = 1;
hoistedFn(); // => 'works' (whole function is available)
function hoistedFn() { return 'works'; }
console.log(tdz); // ReferenceError: Cannot access 'tdz' before initialization
let tdz = 1;

The TDZ is a feature: it converts a silent undefined into a loud error at the exact line of the mistake.

The parser inserts semicolons where a statement clearly ended at a line break. This is why code without semicolons usually works — and why it occasionally does not. Five line starts are dangerous: (, [, `, +, -, and /.

const a = 1
const b = a
;[1, 2].forEach((n) => console.log(n))

Without that leading semicolon, const b = a followed by [1, 2] parses as a[1, 2] — an index access, not a new statement.

The reverse trap: return is always terminated at a line break.

function broken() {
return
{ ok: true };
}
broken(); // => undefined — ASI inserted a semicolon after `return`
function fixed() {
return {
ok: true,
};
}

'use strict' at the top of a file or function opts into a stricter dialect. It turns silent failures into errors and removes some legacy behaviors.

'use strict';
undeclared = 5; // ReferenceError (without strict mode this creates a global)

What strict mode changes:

  • Assigning to an undeclared variable throws instead of creating a global.
  • Assigning to a non-writable property, or to a getter-only property, throws instead of failing silently.
  • this in a plain function call is undefined instead of the global object.
  • Duplicate parameter names and octal literals like 010 are syntax errors.
  • delete on a plain variable is a syntax error.
  • eval gets its own scope and cannot leak variables into the caller.
  • Eight types: seven primitives (number, bigint, string, boolean, undefined, null, symbol) plus object. typeof null is 'object' — a permanent historical bug.
  • All numbers are 64-bit floats. Integers are exact only up to Number.MAX_SAFE_INTEGER; 0.1 + 0.2 !== 0.3. Use BigInt for large exact integers, and never mix it with number.
  • Strings are immutable UTF-16; .length counts code units, not characters. Template literals handle interpolation and multi-line text.
  • undefined is the engine’s absence, null is yours. value == null tests for both.
  • Symbols are unique keys invisible to Object.keys and JSON.stringify.
  • const by default, let when reassigning, never var. const locks the binding, not the object.
  • var is function-scoped and hoisted to undefined; let/const are block-scoped with a TDZ that throws on early access.
  • ASI is why a line starting with ( or [ needs a guard, and why return must never be followed by a line break.
  • ES modules and classes are strict mode by default.