A Practical Look At The "Unknown" Type In Typescript
The unknown type in TypeScript has been a pain point for those entering into the domain of TypeScript. For those who do not understand the purpose of unknown when coming across it for the first time (myself included), then what has been introduced as a way to remove the type un-safety of any can instead lead to losing typing safety through misuse of casting without type narrowing.
In this post, we will look at a practical example of using the unknown type in TypeScript.
This post was inspired by some recent work at our company to remove any mis-casting or use of any in the codebase, and the reference-point for that PR was this article by Marius Schulz in 2019.
Once again, if we check our logs, the compiler fails with the following:
src/index.ts(9,15): error TS2571: Object is of type 'unknown'.
You may have seen (or even yourself used) the following pattern to remedy this:
try {
throw new Error("foo");
} catch (e) {
console.log((e as Error).message);
}
Casting will work, but it is not ideal as it removes the type safety that TypeScript provides.
If e was not actually an Error object, then we would be in a situation where we are trying to access a property on an object that may not exist.
Instead, we can use the instanceof operator to check that the error is an Error object:
try {
throw new Error("foo");
} catch (e) {
if (e instanceof Error) {
console.log(e.message);
} else {
console.log("An error occured");
}
}
Now our error is handled and we can be sure that the message property exists on the e object.
Being a little more practical
Another use case for the unknown type is arbitrary data that is returned from an API.
For example, we may have a function that returns a Promise that resolves to an unknown type. We can emulate this in our code simply by writing an object and declaring it as unknown:
const json: unknown = {
foo: "bar",
};
json.foo; // TypeError: Property 'foo' does not exist on type 'unknown'.
To get the type safety that we want, we can use type predicates to narrow in on the type.
const json: unknown = {
foo: "bar",
};
type Json = {
foo: string;
};
function isJsonType(json: unknown): json is Json {
function hasValidShape(
given: unknown
): given is Partial<Record<keyof Json, unknown>> {
return typeof given === "object" && given !== null;
}
return hasValidShape(json) && typeof json.foo === "string";
}
if (isJsonType(json)) {
json.foo; // string
}
Our isJsonType helper here is a type predicate that narrows the type down to Json if the json object has the correct shape and the foo property is a string.
But what happens for even larger object shapes? You could continue to extend the type predicate that we wrote above, or we could use a library like io-ts to decode the type for us and validate the type within our predicate function with minimal code.
Using io-ts to narrow down an unknown to a known type
We need to install io-ts and fp-ts for this example:
$ yarn add io-ts fp-ts
We can then update our src/index.ts file to look like this:
import * as t from "io-ts";
import { isRight } from "fp-ts/Either";
// ... omitted for brevity
const moreJson: unknown = {
foo: "bar",
baz: {
qux: "quux",
},
bool: true,
};
const moreJsonType = t.type({
foo: t.string,
baz: t.type({
qux: t.string,
}),
bool: t.boolean,
});
type ComplexJson = t.TypeOf<typeof moreJsonType>;
function isComplexJsonType(json: unknown): json is ComplexJson {
return isRight(moreJsonType.decode(json));
}
if (isComplexJsonType(moreJson)) {
moreJson.foo; // string
moreJson.baz.qux; // string
moreJson.bool; // boolean
console.log("moreJson is ComplexJson");
} else {
console.log("moreJson is not a ComplexJson");
}
We have created a new ComplexJson type that has more properties than our previous example.
We can then use ts-io to help create a way to decode our type and use the isRight function to validate that the decode value is a Right value (we won't touch the Either monad result stuff here, but if you are unfamiliar with functional programming then just know that isRight will help confirm if our type is decoded successfully and valid). Wrapping this up in a helper function isComplexJsonType allows us to use the type predicate to narrow down the type.
Throughout this tutorial, while yarn dev has been running, tsup has compiled our code and placed it in the dist folder. If we run node dist/index.js we will see the following output:
# Run the compiled code
$ node dist/index.js
moreJson is ComplexJson
Cool, so it looks like our moreJson object adheres to the ComplexJson type requirements.
If we update our code to comment out the bool property:
import * as t from "io-ts";
import { isRight } from "fp-ts/Either";
// ... omitted for brevity
const moreJson: unknown = {
foo: "bar",
baz: {
qux: "quux",
},
// bool: true,
};
const moreJsonType = t.type({
foo: t.string,
baz: t.type({
qux: t.string,
}),
bool: t.boolean,
});
type ComplexJson = t.TypeOf<typeof moreJsonType>;
function isComplexJsonType(json: unknown): json is ComplexJson {
return isRight(moreJsonType.decode(json));
}
if (isComplexJsonType(moreJson)) {
moreJson.foo; // string
moreJson.baz.qux; // string
moreJson.bool; // boolean
console.log("moreJson is ComplexJson");
} else {
console.log("moreJson is not a ComplexJson");
}
...then we will see the following output when we run the compiled code again:
# Run the compiled code
$ node dist/index.js
moreJson is not a ComplexJson
In this way, we can confirm that our narrowing function has worked and we can be sure that our moreJson object adheres to the ComplexJson type requirements.
Summary
Today's post was a review of the unknown type and to to see how we can use it to help us write safer code.
We looked at type narrowing, type predicates, and how we can use io-ts to help us decode our types and validate them.
I personally have misused unknown before by overriding the benefits with type casting, so it has been nice to review and write about the more common use cases for the unknown type. May you not make the same mistakes that I did when first encountering the type!