TypeScript's 'unknown' type and why you should use it

5 February 2020

Two great things about TypeScript are its modern inferred type system and its ability to interact with JavaScript code. A strong type system helps build robust applications that can easily be refactored and maintained, while JavaScript interop gives developers access to the enormous JavaScript ecosystem including all the modules available on NPM.

However, these two features are often in conflict with each other. JavaScript is a dynamic language without static types, so how can a statically typed language like TypeScript interact with non-typed JavaScript code?

One solution is creating declaration files to annotate JavaScript code with TypeScript types. Tools like DefinitelyTyped retrofit types onto many NPM modules, however there will always be times when TypeScript doesn’t know the types for JavaScript code. The language caters for this scenario with two builtin types: any and unknown. They are similar but have subtle differences that can have a big impact on the reliability of your TypeScript code. The TLDR is you should always use the unknown type over the any type. Here’s why:

The any type

Like the name suggests, The any type represents values that can be of any type. TypeScript will treat the value like it’s any conceivable type possible. That includes primitive types like string and number as well as complex types like Date and objects. any effectively turns off all type checking. Anything goes, and that's the problem with the any type. As the value can be absolutely anything and the compiler cannot check for errors, runtime errors will be much more likely. If the any type was personified, it would look something like this:

any effectively turns off the the benefits of TypeScript’s type system and you may as well be writing pain JavaScript with no type safety.

So why have the any type in TypeScript at all? One major reason is that until unknown (see below), any was the only way to allow TypeScript to interact with JavaScript code that didn’t include type annotations via a declaration file. If you import non-typed JavaScript code into a TypeScript project, the type system has to assign the functions and values a type. But what type to use as the values could be anything? Hence the any type was created.

any begets any

any also has the nasty habit of spreading throughout a codebase unless it’s used with strict discipline. If any is used as the return value of a function it's very easy for code that imports that function to also start using any. Take this code snippet for example that leans on TypeScript’s inferred typing rather than explicit typing:

/* --- declarations.d.ts --- */
declare module "some-js-module";


/* --- main.ts --- */
// An NPM module without TypeScript types
import { getValue } from "some-js-module";

// No return type set, so TypeScript will infer the type from the code
function process(input: number) { 
	// `value` is `any`
	const value = getValue(input); 

	// Returns an inferred type of any
 	return value.toUpperCase(); 
} 

Because the process function did not explicitly set a type, it’s return type is now any because it returned the value of getValue (i.e. any). If another section of the project imported the process function the same problem would happen.

One solution is to use a tool such as eslint to check that all functions explicitly set their return type:


function process(input: number): string { 
   // `any` can be cast to any type, in this case `string`
   const value: string = getValue(input);
   
   return value.toUpperCase();
}

The type signature of process now returns a string, however this is a lie! We don’t know for certain what type getValue returns and now we have the problem that any code that calls process cannot trust the return type it claims. The integrity of the type system starts to break down as you can no longer trust it.

What we need is a better way to interact with values of unknown types that doesn’t erode the benefits of TypesScript’s type system. That’s where the unknown type comes in.

The unknown type

In my experience, 99% of the time any is really used to communicate that we don’t know what type the value is - it’s completely unknown to us. The unknown type was introduced in TypeScript 3.0 just over 5 years after the language hit 1.0. As it’s relatively new it’s not as widely used as its any cousin and that’s a shame because unknown enforces stricter type checking than any. It also solves the problem of anys propagating throughout a codebase and diluting the type system. It’s time for another cheesy comic:

To put it another way, any will pretend to be any type imaginable and effectively turn off type checking, whereas unknown forces you to prove that a value is a certain type before TypeScript will treat it like that type. The way to turn an unknown value into a specific type is done through Type Guards.

Type Guards

Type Guards are used to test if a value is a specific type and it’s how we can convert an unknown value into something useable. This is a simple example of a type guard that checks if a value is a string:

function isString(value: any | unknown): value is string {
	return typeof(value) === "string";
}

Type Guards are functions that return a boolean result, however their return type uses the is keyword and not a simple a boolean. The isString Type Guard we created takes a value (either any or unknown) and returns a boolean. If the returned value is true, TypeScript will treat references of value as a string within the relative scope (such as within an if statement). Let’s use our isString Type Guard and unknowns in the process function:

/* --- declarations.d.ts --- */
declare module "some-js-module";


/* --- main.ts --- */
// An NPM module without TypeScript types
import { getValue } from "some-js-module"; 

function isString(value: any | unknown): value is string {
	return typeof(value) === "string";
}

function process(input: number): string { 
	// We have cast `any` to `unknown`
	const value: unknown = getValue(input);
    
    if (isString(value)) {
    	// `value` is treated as a string
    	return value.toUpperCase(); 
    }
    throw new Error(
    	"Expected a string from getValue, " +
        `got ${typeof(value)} instead`
    );
}

The isString Type Guard tells TypeScript that value is in fact a string and all the usual code completion and type checking will work as normal. To illustrate this point, TypeScript would throw a build error if we tried to use any string properties on value before we proved it was a string:

function process(input: number) {
	const value: unknown = getValue(input);
	if (isString(value)) {
    	// OK
		return value.toUpperCase(); 
    } else {
	    // BUILD ERROR: 'Object is of type 'unknown'
		return value.toLowerCase(); 
	}
}

Type Guards when used along side unknown provide much more reliable code with less runtime errors than when compared to using any. However, it’s important to throughly test your Type Guard functions as the TypeScript type system completely trusts what type casting the functions perform.

Hopefully you now have a better understanding of any, why it exists in TypeScript and how unknown was added to the language to address any’s shortcomings. Because unknown helps to reduce runtime errors and maintain trust in the type system I high recommend using it over any where possible. If you must use any, casting it to unknown is a good compromise.