Marius Schulz
Marius Schulz
Software Engineer

The unknown Type in TypeScript

TypeScript 3.0 introduced a new unknown type, which is the type-safe counterpart of the any type.

The main difference is that unknown is far less permissive: before we can perform most operations on a value of type unknown, we have to check its type first. With a value of type any, no checks are required at all.

This post focuses on the practical aspects of the unknown type, including a comparison with the any type. For a comprehensive code example showing the semantics of the unknown type, check out Anders Hejlsberg's original pull request.

The any Type #

Let's first look at the any type to understand the motivation behind introducing unknown.

The any type has been in TypeScript since the first release in 2012. It represents all possible JavaScript values — primitives, objects, arrays, functions, errors, symbols, what have you.

In TypeScript, every type is assignable to any. This makes any a top type (also known as a universal supertype) of the type system.

Here are a few examples of values that we can assign to a variable of type any:

let value: any;

value = true; // OK
value = 42; // OK
value = "Hello World"; // OK
value = []; // OK
value = {}; // OK
value = Math.random; // OK
value = null; // OK
value = undefined; // OK
value = new TypeError(); // OK
value = Symbol("type"); // OK

The any type is essentially an escape hatch from the type system. It gives us a ton of freedom: TypeScript lets us perform any operation we want on a value of type any without any checking beforehand.

Since value is typed as any, TypeScript considers all of the following operations type-correct:

let value: any;

value.foo.bar; // OK
value.trim(); // OK
value(); // OK
new value(); // OK
value[0][1]; // OK

In many cases, this is too permissive. Using any, it's easy to write code that is type-correct but problematic at runtime. We don't get a lot of protection from TypeScript if we opt to use any.

What if there were a top type that was safe by default? This is where unknown comes into play.

The unknown Type #

Just like all types are assignable to any, all types are assignable to unknown. This makes unknown another top type of TypeScript's type system (the other one being any).

Here's the same list of assignment examples we saw before, this time using a variable typed as unknown:

let value: unknown;

value = true; // OK
value = 42; // OK
value = "Hello World"; // OK
value = []; // OK
value = {}; // OK
value = Math.random; // OK
value = null; // OK
value = undefined; // OK
value = new TypeError(); // OK
value = Symbol("type"); // OK

All of these assignments are type-correct.

What happens, though, when we try to assign a value of type unknown to variables of other types?

let value: unknown;

let value1: unknown = value; // OK
let value2: any = value; // OK
let value3: boolean = value; // Error
let value4: number = value; // Error
let value5: string = value; // Error
let value6: object = value; // Error
let value7: any[] = value; // Error
let value8: Function = value; // Error

The unknown type is only assignable to any and to unknown itself. Intuitively, this makes sense: we know nothing about what's stored in value, so only a container that can hold values of arbitrary type can hold it.

Let's now see what happens when we perform operations on a value of type unknown — the same operations as before:

let value: unknown;

value.foo.bar; // Error
value.trim(); // Error
value(); // Error
new value(); // Error
value[0][1]; // Error

With value typed as unknown, none of these operations are type-correct anymore. Going from any to unknown, we've flipped the default from permitting everything to permitting (almost) nothing.

This is the main benefit of the unknown type: TypeScript won't let us perform arbitrary operations on the value until we've narrowed its type through some form of checking.

Narrowing the unknown Type #

We can narrow the unknown type to a more specific type in different ways, including the typeof operator, the instanceof operator, and custom type guard functions. All of these narrowing techniques contribute to TypeScript's control flow based type analysis.

The following example illustrates how value has a more specific type within the two if statement branches:

function stringifyForLogging(value: unknown): string {
  if (typeof value === "function") {
    // Within this branch, `value` has type `Function`,
    // so we can access the function's `name` property
    const functionName = value.name || "(anonymous)";
    return `[function ${functionName}]`;
  }

  if (value instanceof Date) {
    // Within this branch, `value` has type `Date`,
    // so we can call the `toISOString` method
    return value.toISOString();
  }

  return String(value);
}

In addition to using the typeof or instanceof operators, we can also narrow the unknown type using a custom type guard function:

/**
 * A custom type guard function that determines whether
 * `value` is an array that only contains numbers.
 */
function isNumberArray(value: unknown): value is number[] {
  return (
    Array.isArray(value) && value.every(element => typeof element === "number")
  );
}

const unknownValue: unknown = [15, 23, 8, 4, 42, 16];

if (isNumberArray(unknownValue)) {
  // Within this branch, `unknownValue` has type `number[]`,
  // so we can spread the numbers as arguments to `Math.max`
  const max = Math.max(...unknownValue);
  console.log(max);
}

Notice how unknownValue has type number[] within the if statement branch although it is declared to be of type unknown.

Using Type Assertions with unknown #

The checks from the previous section (typeof, instanceof, and custom type guard functions) are the safe and recommended way to narrow a value of type unknown.

If you want to force the compiler to trust you, you can use a type assertion like this:

const value: unknown = "Hello World";
const someString: string = value as string;
const otherString = someString.toUpperCase(); // "HELLO WORLD"

TypeScript doesn't perform any special checks to make sure the assertion is actually valid; the type checker simply assumes that you know better.

If you make a mistake and specify an incorrect type, you'll get an error at runtime:

const value: unknown = 42;
const someString: string = value as string;
const otherString = someString.toUpperCase(); // BOOM

The value variable holds a number, but we're pretending it's a string using the type assertion value as string. Be careful with type assertions!

The unknown Type in Union Types #

Let's look at how unknown behaves in union types; intersection types come next.

In a union type, unknown absorbs every type. This means that if any of the constituent types is unknown, the union type evaluates to unknown:

type UnionType1 = unknown | null; // unknown
type UnionType2 = unknown | undefined; // unknown
type UnionType3 = unknown | string; // unknown
type UnionType4 = unknown | number[]; // unknown

The one exception to this rule is any. If at least one of the constituent types is any, the union type evaluates to any:

type UnionType5 = unknown | any; // any

So why does unknown absorb every type (aside from any)? Let's think about the unknown | string example: it represents all values assignable to unknown plus those assignable to string. Since all types (including all strings) are assignable to unknown, unknown | string represents the same set of values as unknown itself, and the compiler can simplify the union type accordingly.

The unknown Type in Intersection Types #

In an intersection type, every type absorbs unknown. This means that intersecting any type with unknown doesn't change the resulting type:

type IntersectionType1 = unknown & null; // null
type IntersectionType2 = unknown & undefined; // undefined
type IntersectionType3 = unknown & string; // string
type IntersectionType4 = unknown & number[]; // number[]
type IntersectionType5 = unknown & any; // any

Let's look at IntersectionType3: the unknown & string type represents all values that are assignable to both unknown and string. Since everything is assignable to unknown, adding it to an intersection changes nothing. We're left with just string.

Using Operators with Values of Type unknown #

Values of type unknown can't be used as operands for most operators, and for good reason: most operators are unlikely to produce a meaningful result if we don't know the types of the values we're working with.

The only operators you can use on values of type unknown are the four equality and inequality operators:

  • ===
  • ==
  • !==
  • !=

If you want to use any other operators on a value typed as unknown, you have to narrow the type first (or force the compiler to trust you using a type assertion).

Example: Reading JSON from localStorage #

Let's finish with a real-world example: a function that reads a value from localStorage and deserializes it as JSON. If the item doesn't exist or isn't valid JSON, the function should return an error result; otherwise, it should deserialize and return the value.

We can't know what we'll get back after parsing the persisted JSON string, so we'll type the deserialized value as unknown. Callers of our function will have to check what they got before using it (or reach for a type assertion).

Here's how we could implement that function:

type Result =
  | { success: true; value: unknown }
  | { success: false; error: Error };

function tryDeserializeLocalStorageItem(key: string): Result {
  const item = localStorage.getItem(key);

  if (item === null) {
    // The item does not exist, thus return an error result
    return {
      success: false,
      error: new Error(`Item with key "${key}" does not exist`),
    };
  }

  let value: unknown;

  try {
    value = JSON.parse(item);
  } catch (error) {
    // The item is not valid JSON, thus return an error result
    return {
      success: false,
      error,
    };
  }

  // Everything's fine, thus return a success result
  return {
    success: true,
    value,
  };
}

The Result return type is a tagged union type, also known as a discriminated union type. You might have seen it as Maybe, Option, or Optional in other languages. It lets us model the success and failure cases cleanly.

Callers of the tryDeserializeLocalStorageItem function have to inspect the success property before attempting to use the value or error properties:

const result = tryDeserializeLocalStorageItem("dark_mode");

if (result.success) {
  // We've narrowed the `success` property to `true`,
  // so we can access the `value` property
  const darkModeEnabled: unknown = result.value;

  if (typeof darkModeEnabled === "boolean") {
    // We've narrowed the `unknown` type to `boolean`,
    // so we can safely use `darkModeEnabled` as a boolean
    console.log("Dark mode enabled: " + darkModeEnabled);
  }
} else {
  // We've narrowed the `success` property to `false`,
  // so we can access the `error` property
  console.error(result.error);
}

The tryDeserializeLocalStorageItem function can't simply return null to signal failure, for two reasons:

  1. null is a valid JSON value, so we couldn't tell a successfully deserialized null apart from a missing item or a syntax error.
  2. Returning null leaves no room for the error itself, so callers would never learn why the operation failed.

For the sake of completeness, a more sophisticated alternative is to use typed decoders for safe JSON parsing. A decoder lets us declare the schema we expect, and if the persisted JSON doesn't match it, decoding fails in a well-defined way. Our function would then always return either a valid or a failed decoding result, and we could drop the unknown type altogether.

This article and 44 others are part of the TypeScript Evolution series. Have a look!