Skip to content

Eight types — seven primitives and the object

Level: 101 · for anyone who has written a line of JavaScript

One line: Every value has one of eight types — seven primitives (undefined, null, boolean, number, bigint, string, symbol) and Object — and Object(v) === v is true only for an object; a primitive takes no property and never changes, while arrays, functions, dates, regular expressions and the instances of your own classes are kinds of Object, not types of their own.

Keywords: primitive, data types, immutable

The specification names eight types, and every value belongs to exactly one of them. The program below takes the twelve values this page asks about — undefined, null, true, 1, 1n, "s", Symbol(), {}, [], a function, a Date and a regular expression — and sorts them with a single test, Object(v) === v, without using typeof.

The program

eight_types_classify_js.js in full — pasted here by tools/run_examples.py from the file CI runs.

// Twelve values, sorted the way the language sorts them. Object(v) hands back
// v itself only when v is already an object; for a primitive it has to make a
// new object, so Object(v) === v is the test. No typeof anywhere.
function classify(value) {
  const made = Object(value);
  if (made === value) {
    return { same: true, made: "the same object", is: "object", type: "Object" };
  }
  if (Object.getPrototypeOf(made) === Object.prototype) {
    // Only undefined and null get a new, empty, ordinary object.
    return { same: false, made: "a new empty object", is: "primitive", type: value === null ? "Null" : "Undefined" };
  }
  // Every other primitive gets a wrapper, named after its type.
  const wrapper = made.constructor.name;
  return { same: false, made: `a ${wrapper} wrapper`, is: "primitive", type: wrapper };
}

const samples = [
  ["undefined", undefined],
  ["null", null],
  ["true", true],
  ["1", 1],
  ["1n", 1n],
  ['"s"', "s"],
  ["Symbol()", Symbol()],
  ["{}", {}],
  ["[]", []],
  ["() => {}", () => {}],
  ["new Date(0)", new Date(0)],
  ["/s/", /s/],
];

console.log(`${"value".padEnd(13)}${"Object(v) === v".padEnd(17)}${"Object(v) returns".padEnd(21)}${"so it is".padEnd(11)}type`);
const types = [];
let primitives = 0;
for (const [label, value] of samples) {
  const row = classify(value);
  // For an object, name the constructor that made it: a kind of object, not a type.
  const type = row.same ? `Object, made by ${value.constructor.name}` : row.type;
  console.log(`${label.padEnd(13)}${String(row.same).padEnd(17)}${row.made.padEnd(21)}${row.is.padEnd(11)}${type}`);
  if (!types.includes(row.type)) types.push(row.type);
  if (!row.same) primitives += 1;
}
console.log(`${samples.length} values: ${primitives} primitives, ${samples.length - primitives} objects`);
console.log(`${types.length} types: ${types.join(", ")}`);

Verified output of eight_types_classify_js.js — regenerated by tools/run_examples.py, never hand-typed.

value        Object(v) === v  Object(v) returns    so it is   type
undefined    false            a new empty object   primitive  Undefined
null         false            a new empty object   primitive  Null
true         false            a Boolean wrapper    primitive  Boolean
1            false            a Number wrapper     primitive  Number
1n           false            a BigInt wrapper     primitive  BigInt
"s"          false            a String wrapper     primitive  String
Symbol()     false            a Symbol wrapper     primitive  Symbol
{}           true             the same object      object     Object, made by Object
[]           true             the same object      object     Object, made by Array
() => {}     true             the same object      object     Object, made by Function
new Date(0)  true             the same object      object     Object, made by Date
/s/          true             the same object      object     Object, made by RegExp
12 values: 7 primitives, 5 objects
8 types: Undefined, Null, Boolean, Number, BigInt, String, Symbol, Object

Reading the output

  • The test. Object(v) turns v into an object. When v is already an object there is nothing to do, and it returns v itself, so the comparison is true: the last five rows. For a primitive it must return some other value, an object, so the comparison is false: the first seven rows. Those are the steps ECMA-262 gives for Object ( value ) ↗.
  • undefined and null get a new, empty, ordinary object: they are the two primitives with no wrapper (MDN — Primitive values ↗). The program names their types by comparing with undefined and null directly.
  • The other five primitives each get a wrapper object, and the wrapper's constructor carries the name of the type: Boolean, Number, BigInt, String, Symbol. A property read on a primitive, such as "s".length, is looked up on a wrapper like these (MDN — Primitive ↗); Primitives and wrappers is the page about that.
  • {}, [], the arrow function, the Date and the regular expression are all of one type, Object. What differs is the constructor that made each of them — Object, Array, Function, Date, RegExp — so an array or a function is a kind of object, not a type.
  • The last two lines count the rows: seven primitives and five objects, spread over eight types. That is the whole of ECMA-262's list: Undefined, Null, Boolean, String, Symbol, Number, BigInt and Object (ECMA-262 — ECMAScript language types ↗), where a primitive value is a member of any of the first seven (ECMA-262 — primitive value ↗).

What can an object do that a string cannot?

Three things: hold a property of its own, change in place, and be told apart from another object with the same contents. The program tries each on an object, order, and on a string, code. The file is an ES module, and module code always runs in strict mode, where an assignment that cannot happen throws a TypeError (MDN — Strict mode for modules ↗); the program catches each error and prints its message.

The program

eight_types_immutable_primitives_js.js in full — pasted here by tools/run_examples.py from the file CI runs.

// Three things an object can do, tried on an object and then on a string.
// This file is an ES module, so it runs in strict mode: an assignment that
// cannot happen throws a TypeError instead of being ignored.
function attempt(label, action) {
  let result;
  try {
    result = action();
  } catch (error) {
    result = `${error.name}: ${error.message}`;
  }
  console.log(`   ${label.padEnd(28)}${result}`);
}

const order = { code: "ab-100" }; // an object
let code = "ab-100"; // a string, one of the seven primitives

console.log("1. hold a property of its own");
attempt('order.note = "fragile"', () => {
  order.note = "fragile";
  return `order.note is "${order.note}"`;
});
attempt('code.note = "fragile"', () => {
  code.note = "fragile";
  return `code.note is "${code.note}"`;
});

console.log("2. change in place");
attempt('order.code = "AB-100"', () => {
  order.code = "AB-100";
  return `order is ${JSON.stringify(order)}`;
});
attempt('code[0] = "A"', () => {
  code[0] = "A";
  return `code is "${code}"`;
});
attempt("code.toUpperCase()", () => `returns "${code.toUpperCase()}"; code is still "${code}"`);
attempt("code = code.toUpperCase()", () => {
  code = code.toUpperCase();
  return `code is "${code}": a new string in the same variable`;
});

console.log("3. be compared by identity");
const first = { code: "ab-100" };
const second = { code: "ab-100" };
attempt("first === second", () => `${first === second}: two objects, built alike`);
attempt("first === first", () => `${first === first}: one object`);
attempt('"ab-100" === "ab-" + "100"', () => `${"ab-100" === "ab-" + "100"}: two strings, same characters`);

Verified output of eight_types_immutable_primitives_js.js — regenerated by tools/run_examples.py, never hand-typed.

1. hold a property of its own
   order.note = "fragile"      order.note is "fragile"
   code.note = "fragile"       TypeError: Cannot create property 'note' on string 'ab-100'
2. change in place
   order.code = "AB-100"       order is {"code":"AB-100","note":"fragile"}
   code[0] = "A"               TypeError: Cannot assign to read only property '0' of string 'ab-100'
   code.toUpperCase()          returns "AB-100"; code is still "ab-100"
   code = code.toUpperCase()   code is "AB-100": a new string in the same variable
3. be compared by identity
   first === second            false: two objects, built alike
   first === first             true: one object
   "ab-100" === "ab-" + "100"  true: two strings, same characters
  • 1. A property of its own. The object takes note. The string refuses it with TypeError: Cannot create property 'note' on string 'ab-100': a primitive has nowhere to keep a property. In sloppy code the same assignment is ignored without a word instead, and the string still has no note (MDN — Strict mode: setting properties on primitive values ↗).
  • 2. Change in place. The object changes, and keeps the note from step 1. code[0] = "A" throws, because a string's characters are read-only properties (ECMA-262 — String exotic objects ↗). toUpperCase() leaves code alone as well: it returns a new string. What can change is the variable — code = code.toUpperCase() puts a new string into it, which is why code is declared with let. MDN's glossary draws the same line between a primitive and a variable that holds one (MDN — Primitive ↗).
  • 3. Identity. Each object literal makes a new object, so first === second is false although the two look the same; an object is === only to itself. Two strings have no identity to tell apart: === compares their characters (ECMA-262 — SameValueNonNumber ↗), so "ab-100" equals "ab-" + "100". Values and references follows the difference into assignment and function calls.

Why are there two numeric types?

Until ES2020 there was one: the list of types in ES2019 has Number as its only numeric type, and BigInt joins the list in ES2020 (ES2019 — ECMAScript language types ↗, ES2020 — ECMAScript language types ↗). The program shows what a number cannot do and a bigint can.

eight_types_two_numbers_js.js in full — pasted here by tools/run_examples.py from the file CI runs.

// Two numeric types. A number is a 64-bit double, whole or not, and holds
// whole numbers exactly only up to 2 ** 53. A bigint stays exact past that,
// and holds whole numbers only.
const rows = [
  ["typeof 1", JSON.stringify(typeof 1)],
  ["typeof 1.5", JSON.stringify(typeof 1.5)],
  ["typeof 1n", JSON.stringify(typeof 1n)],
  ["1 === 1.0", String(1 === 1.0)],
  ["1 === 1n", String(1 === 1n)],
  ["Number.MAX_SAFE_INTEGER", String(Number.MAX_SAFE_INTEGER)],
  ["2 ** 53 + 1", String(2 ** 53 + 1)],
  ["2 ** 53 + 1 === 2 ** 53", String(2 ** 53 + 1 === 2 ** 53)],
  ["2n ** 53n + 1n", `${2n ** 53n + 1n}n`],
  ["2n ** 53n + 1n === 2n ** 53n", String(2n ** 53n + 1n === 2n ** 53n)],
  ["7 / 2", String(7 / 2)],
  ["7n / 2n", `${7n / 2n}n`],
];
for (const [expression, result] of rows) {
  console.log(`${expression.padEnd(30)}${result}`);
}

Verified output of eight_types_two_numbers_js.js — regenerated by tools/run_examples.py, never hand-typed.

typeof 1                      "number"
typeof 1.5                    "number"
typeof 1n                     "bigint"
1 === 1.0                     true
1 === 1n                      false
Number.MAX_SAFE_INTEGER       9007199254740991
2 ** 53 + 1                   9007199254740992
2 ** 53 + 1 === 2 ** 53       true
2n ** 53n + 1n                9007199254740993n
2n ** 53n + 1n === 2n ** 53n  false
7 / 2                         3.5
7n / 2n                       3n
  • typeof answers "number" for 1 and for 1.5. Whole or fractional, a number is one type, and 1 === 1.0 is true because 1.0 is the same value as 1 (Every number is a double). typeof 1n is "bigint", and 1 === 1n is false: === is false whenever the two types differ (ECMA-262 — IsStrictlyEqual ↗).
  • A number runs out of whole numbers. A number is a 64-bit IEEE 754 double (ECMA-262 — The Number Type ↗). Number.MAX_SAFE_INTEGER is 9007199254740991, 2^53 − 1, and just past 2^53 the doubles skip a whole number: 2 ** 53 + 1 prints 9007199254740992 and is === to 2 ** 53 (MDN — Number.MAX_SAFE_INTEGER ↗). Safe integers maps where the gaps are.
  • A bigint stays exact past 2^53, and holds only whole numbers. 2n ** 53n + 1n prints 9007199254740993n and differs from 2n ** 53n; 7 / 2 is 3.5, but 7n / 2n is 3n. What happens when the two types meet in one expression is the subject of BigInt.

Why can you not add a ninth type?

The list is part of the language, and only a new edition of the standard changes it: ES5.1 had six types, ES2015 added Symbol, and ES2020 added BigInt (ES5.1 ↗, ES2015 ↗, ES2020 ↗). Nothing a program writes adds to it. What a program writes instead is a class, so the program asks what a class makes, then runs the first program's test over a dozen other things a program can build.

The program

eight_types_no_ninth_js.js in full — pasted here by tools/run_examples.py from the file CI runs.

// A program cannot add a ninth type. What it writes instead is a class, and a
// class is a function that builds objects. Everything else a program can make
// passes the same test as {} did: Object(v) is v itself.
class Money {
  constructor(cents, currency) {
    this.cents = cents;
    this.currency = currency;
  }
}
const price = new Money(1999, "USD");

const facts = [
  ["typeof Money", JSON.stringify(typeof Money)],
  ["typeof price", JSON.stringify(typeof price)],
  ["Object.getPrototypeOf(price) === Money.prototype", String(Object.getPrototypeOf(price) === Money.prototype)],
];
for (const [expression, result] of facts) {
  console.log(`${expression.padEnd(50)}${result}`);
}
console.log("");

const made = [
  ["Money", Money],
  ["price", price],
  ["new Map()", new Map()],
  ["new Set()", new Set()],
  ['new Error("out of stock")', new Error("out of stock")],
  ["Promise.resolve(price)", Promise.resolve(price)],
  ["new Proxy(price, {})", new Proxy(price, {})],
  ["new Uint8Array(4)", new Uint8Array(4)],
  ["Object.create(null)", Object.create(null)],
  ["Object(1999n)", Object(1999n)],
  ["Math", Math],
  ["globalThis", globalThis],
];
console.log(`${"value".padEnd(27)}Object(v) === v`);
let objects = 0;
for (const [label, value] of made) {
  const isObject = Object(value) === value;
  if (isObject) objects += 1;
  console.log(`${label.padEnd(27)}${isObject}`);
}
console.log(`${objects} of ${made.length} are objects: one type, Object`);

Verified output of eight_types_no_ninth_js.js — regenerated by tools/run_examples.py, never hand-typed.

typeof Money                                      "function"
typeof price                                      "object"
Object.getPrototypeOf(price) === Money.prototype  true

value                      Object(v) === v
Money                      true
price                      true
new Map()                  true
new Set()                  true
new Error("out of stock")  true
Promise.resolve(price)     true
new Proxy(price, {})       true
new Uint8Array(4)          true
Object.create(null)        true
Object(1999n)              true
Math                       true
globalThis                 true
12 of 12 are objects: one type, Object
  • The class is a function. typeof Money is "function"; why typeof gives callable objects an answer of their own is on the typeof page, and what else a class is, on Classes.
  • What it builds is an object. typeof price is "object", and price's prototype is Money.prototype. The class added a kind of object — one more link in a prototype chain — not a type.
  • Everything else is an object too. The class, its instance, a Map, a Set, an Error, a Promise, a Proxy, a typed array, an object with no prototype at all, the wrapper object around a BigInt, Math and the global object all pass the test that {} passed in the first program: twelve different kinds, one type.

Does tsc know a string is not an object?

It does, and it says so before anything runs. The program repeats one assignment from What can an object do that a string cannot?, then offers a string where TypeScript's type object is wanted.

eight_types_primitive_tserror.ts in full — pasted here by tools/run_examples.py from the file CI runs.

// tsc draws the same line before anything runs. It will not let a program
// change a character of a string, and it will not accept a string where the
// type `object` (any value that is not a primitive) is wanted.
let code = "ab-100";
code[0] = "A";

const order: object = { code };
const label: object = code;

What tsc says about eight_types_primitive_tserror.ts — regenerated by tools/run_examples.py, never hand-typed.

eight_types_primitive_tserror.ts:5:1 - error TS2542: Index signature in type 'String' only permits reading.

5 code[0] = "A";
  ~~~~~~~

eight_types_primitive_tserror.ts:8:7 - error TS2322: Type 'string' is not assignable to type 'object'.

8 const label: object = code;
        ~~~~~


Found 2 errors in the same file, starting at: eight_types_primitive_tserror.ts:5
  • Line 5, TS2542. code[0] = "A", which threw a TypeError when the second program ran, does not get past the check. The message says String with a capital S although code has the type string; Primitive and literal types is the page on that difference.
  • Line 8, TS2322. The TypeScript handbook defines object as any value that is not a primitive (TypeScript Handbook — More on Functions: object ↗). Line 7 puts an object literal into it and passes; line 8 tries the string and fails. It is the line Object(v) === v draws, drawn at compile time. object, Object and {} sets it beside two types that sound the same.

What to do

  • To ask whether a value is a primitive, compare Object(value) !== value; to ask for one particular type, use typeof or value === null.
  • Treat a primitive as finished: to change a string, build a new one and assign it to the variable.
  • Keep information about a primitive in an object beside it, never on the primitive itself.
  • Use === on two objects only when you mean "the same object"; to compare contents, compare their fields.
  • Use bigint for whole numbers that can pass 2^53, and number for everything else.
  • Write a class when you need a new kind of object, and expect it to be of type Object like every other.

In other languages

Practice

Kata: primitives on one line, objects on the other

Write isPrimitive(value) without typeof, and use it to sort these twelve values, kept in this order: 1999, "1999", 1999n, true, null, undefined, Symbol("sku"), [1999], { cents: 1999 }, () => 1999, new Date(0) and new Number(1999). Print each value as it is written in the source, so that the program prints exactly this:

Verified output of eight_types_kata_js.js — regenerated by tools/run_examples.py, never hand-typed.

primitives (7): 1999, "1999", 1999n, true, null, undefined, Symbol("sku")
objects (5): [1999], { cents: 1999 }, () => 1999, new Date(0), new Number(1999)

One of the twelve holds a number and is still an object.

Solution

eight_types_kata_js.js in full — pasted here by tools/run_examples.py from the file CI runs.

// Kata solution: sort twelve values into primitives and objects without typeof.
// Object(value) returns value itself only when value is already an object.
const isPrimitive = (value) => Object(value) !== value;

const values = [
  ["1999", 1999],
  ['"1999"', "1999"],
  ["1999n", 1999n],
  ["true", true],
  ["null", null],
  ["undefined", undefined],
  ['Symbol("sku")', Symbol("sku")],
  ["[1999]", [1999]],
  ["{ cents: 1999 }", { cents: 1999 }],
  ["() => 1999", () => 1999],
  ["new Date(0)", new Date(0)],
  ["new Number(1999)", new Number(1999)],
];

const primitives = values.filter(([, value]) => isPrimitive(value)).map(([label]) => label);
const objects = values.filter(([, value]) => !isPrimitive(value)).map(([label]) => label);
console.log(`primitives (${primitives.length}): ${primitives.join(", ")}`);
console.log(`objects (${objects.length}): ${objects.join(", ")}`);

Sources

See also