diff --git a/14_contains/README.md b/14_contains/README.md
new file mode 100644
index 00000000000..d2dab5789f0
--- /dev/null
+++ b/14_contains/README.md
@@ -0,0 +1,12 @@
+# Exercise 14 - contains
+
+Write a function that searches for a value in a nested object. It returns true if the object contains that value.
+
+Objects are compared by reference.
+
+Examples:
+
+```javascript
+contains({ foo: "foo" }, "bar") // false
+contains({ foo: { bar: "bar" } }, "bar") // true
+```
diff --git a/14_contains/contains.js b/14_contains/contains.js
new file mode 100644
index 00000000000..d5c798c74b7
--- /dev/null
+++ b/14_contains/contains.js
@@ -0,0 +1,6 @@
+const contains = function() {
+  
+};
+  
+// Do not edit below this line
+module.exports = contains;
diff --git a/14_contains/contains.spec.js b/14_contains/contains.spec.js
new file mode 100644
index 00000000000..5db2eea33d5
--- /dev/null
+++ b/14_contains/contains.spec.js
@@ -0,0 +1,60 @@
+const contains = require("./contains");
+
+describe("contains", () => {
+  const meaningOfLifeArray = [42];
+  const object = {
+    data: {
+      duplicate: "e",
+      stuff: {
+        thing: {
+          banana: NaN,
+          moreStuff: {
+            something: "foo",
+            answer: meaningOfLifeArray,
+          },
+        },
+      },
+      info: {
+        duplicate: "e",
+        magicNumber: 44,
+        empty: null,
+      },
+    },
+  };
+
+  test("true if the provided number is a value within the object", () => {
+    expect(contains(object, 44)).toBe(true);
+  });
+
+  test.skip("true if the provided string is a value within the object", () => {
+    expect(contains(object, "foo")).toBe(true);
+  });
+
+  test.skip("does not convert input string into a number when searching for a value within the object", () => {
+    expect(contains(object, "44")).toBe(false);
+  });
+
+  test.skip("false if the provided string is not a value within the object", () => {
+    expect(contains(object, "bar")).toBe(false);
+  });
+
+  test.skip("true if provided string is within the object, even if duplicated", () => {
+    expect(contains(object, "e")).toBe(true);
+  });
+
+  test.skip("true if the object contains the same object by reference", () => {
+    expect(contains(object, meaningOfLifeArray)).toBe(true);
+  });
+
+  test.skip("false if no matching object reference", () => {
+    expect(contains(object, [42])).toBe(false);
+  });
+
+  test.skip("true if NaN is a value within the object", () => {
+    expect(contains(object, NaN)).toBe(true);
+  });
+
+  test.skip("false if the provided value exists and is null", () => {
+    expect(contains(object, null)).toBe(true);
+  });
+});
diff --git a/14_contains/solution/contains-solution.js b/14_contains/solution/contains-solution.js
new file mode 100644
index 00000000000..13bf75c1f4b
--- /dev/null
+++ b/14_contains/solution/contains-solution.js
@@ -0,0 +1,20 @@
+const contains = function (object, searchValue) {
+  const values = Object.values(object);
+
+  // NaN === NaN evaluates to false
+  // Normally, we would have to do an explicit Number.isNaN() check to compare NaN equality
+  // However, Array.prototype.includes automatically handles this for us
+  if (values.includes(searchValue)) return true;
+
+  const nestedObjects = values.filter(
+    // typeof null === 'object' evaluates to true ¯\_(ツ)_/¯
+    (value) => typeof value === "object" && value !== null
+  );
+
+  return nestedObjects.some((nestedObject) =>
+    contains(nestedObject, searchValue)
+  );
+};
+
+// Do not edit below this line
+module.exports = contains;
diff --git a/14_contains/solution/contains-solution.spec.js b/14_contains/solution/contains-solution.spec.js
new file mode 100644
index 00000000000..b9710923316
--- /dev/null
+++ b/14_contains/solution/contains-solution.spec.js
@@ -0,0 +1,60 @@
+const contains = require("./contains-solution");
+
+describe("contains", () => {
+  const meaningOfLifeArray = [42];
+  const object = {
+    data: {
+      duplicate: "e",
+      stuff: {
+        thing: {
+          banana: NaN,
+          moreStuff: {
+            something: "foo",
+            answer: meaningOfLifeArray,
+          },
+        },
+      },
+      info: {
+        duplicate: "e",
+        magicNumber: 44,
+        empty: null,
+      },
+    },
+  };
+
+  test("true if the provided number is a value within the object", () => {
+    expect(contains(object, 44)).toBe(true);
+  });
+
+  test("true if the provided string is a value within the object", () => {
+    expect(contains(object, "foo")).toBe(true);
+  });
+
+  test("does not convert input string into a number when searching for a value within the object", () => {
+    expect(contains(object, "44")).toBe(false);
+  });
+
+  test("false if the provided string is not a value within the object", () => {
+    expect(contains(object, "bar")).toBe(false);
+  });
+
+  test("true if provided string is within the object, even if duplicated", () => {
+    expect(contains(object, "e")).toBe(true);
+  });
+
+  test("true if the object contains the same object by reference", () => {
+    expect(contains(object, meaningOfLifeArray)).toBe(true);
+  });
+
+  test("false if no matching object reference", () => {
+    expect(contains(object, [42])).toBe(false);
+  });
+
+  test("true if NaN is a value within the object", () => {
+    expect(contains(object, NaN)).toBe(true);
+  });
+
+  test("true if the provided value exists and is null", () => {
+    expect(contains(object, null)).toBe(true);
+  });
+});