DEV Community

Lukas Bach
Lukas Bach

Posted on

TypeScript: The many types of nothing

Almost all programming languages have a special value that can be used to denote that a variable is uninitialized, not yet defined or was set in the context of a corner case. Perl calls it undef, Python calls it None, but most languages refer to it as null. If you've worked with JavaScript before, you know its apparent ambiguity of having two different kinds of this value: null and undefined. TypeScript doesn't necessarily help in clearing up confusion here by introducing three more special types related to type corner cases: never, void, and unknown. Let's explore those types to understand how they work and what they can be used for.

null and undefined during runtime

Null and undefined are the only items of the aforementioned that actually are values that can exist during runtime, the others are only types that exist during compile time and are thrown away by the TypeScript compiler.

We get some interesting insights into those two values by looking at how they interact in terms of equality, notably that both are strictly equal to itself, but only equal to itself with a type conversion, which is what the double-equal operator does:

undefined === undefined; // true
null === null; // true
undefined === null; // false
undefined == null; // true
Enter fullscreen mode Exit fullscreen mode

Using the typeof operator also provides some insights:

typeof undefined; // "undefined"
typeof abc; // "undefined"
typeof null; // "object"
Enter fullscreen mode Exit fullscreen mode

Here, we see that the value undefined is internally used by JS in some cases, in this instance variables that are not defined ("abc") will be equivalent to the value undefined, and a typeof check returns the string "undefined" accordingly. Checking the type of null is not as intuitive by returning "object", a quirk that the book "Professional JS for Web Developers" by Wrox provides some more details on:

You may wonder why the typeof operator returns 'object' for a value that is null. This was actually an error in the original JavaScript implementation that was then copied in ECMAScript. Today, it is rationalized that null is considered a placeholder for an object, even though, technically, it is a primitive value.

In practice, likely the most important difference is that the JS runtime commonly uses undefined in many places to denote that something does not exist; null on the other hand is never used automatically. This gives developers a way of distinguishing between something that does not exist due to never being defined, or something that was intentionally set to null because it denotes an "empty" case. However, this is up to the developer since undefined can very much be assigned manually as well.

There is another oddity of undefined, which allows developers to assign a variable with the name "undefined" and use it sucessfully, something that is not possible with "null", however this is something that should not affect realistic code unless one would actively try to break things.

(() => {
  // Need to use scope, since `undefined` already is defined in the global scope. 
  // Don't think about it too hard...
  const undefined = "hello";
  // const null = "hello"; // would through a ReferenceError
  console.log(undefined); // logs "hello"
})();
Enter fullscreen mode Exit fullscreen mode

undefined and null during compile time

We just looked at how undefined and null behave during runtime. Now let us look at how they are interpreted during compile time by the typescript compiler. This will be a rather short investigation, since typescript defines the respective types "undefined" and "null" and has them behave the same way:

const nullValue = null; // type: null
const undefinedValue = undefined; // type: undefined

function maybeUndefined() {
    return Math.random() > .5 ? null : "hello";
}

const nullOrString = maybeUndefined(); // type: null | "hello"
Enter fullscreen mode Exit fullscreen mode

The type "never"

never is, in my opinion, one of the more interesting types in this list. It is mostly used internally by TypeScripts evaluation rules, but can be very powerful in complex type rules. The TypeScript handbook defines it as the following:

The never type is a subtype of, and assignable to, every type; however, no type is a subtype of, or assignable to, never (except never itself). Even any isn't assignable to never.

TypeScript uses it as an inferred return value of a function which does not return. A function does not return if it either always throws an error:

const neverReturns = () => {
    throw Error("goodbye")
}
// inferred type is "const neverReturns: () => never" even without explicit types
Enter fullscreen mode Exit fullscreen mode

This is also the case if it calls a method that never returns. Note that, in the following example, the return value of neverReturns is not directly used or returned, yet the TypeScript compiler knows that some never-returning function is called during the execution of alsoNeverReturns, and thus alsoNeverReturns is inferred to also never return either.

const neverReturns = () => {
    throw Error("goodbye")
}
const alsoNeverReturns = () => {
    neverReturns()
    return "something"
}
// inferred type is "const alsoNeverReturns: () => void"
Enter fullscreen mode Exit fullscreen mode

Note that, if there is a chance of returning something, a function will not have the return value of never, so just because a function does not have that return type, it is not a guarantee that the function cannot throw.

const maybeReturns = () => {
    if (Math.random() > .5) {
        throw Error("goodbye")
    } 
    return "something"
}
// inferred type is "const maybeReturns: () => string"
Enter fullscreen mode Exit fullscreen mode

Functions that run an endless loop also never return, which the TypeScript compiler correctly infers based on static code analysis, but again a function not returning never is not a guarantee that no endless loop happens.

const neverReturns = () => {
    while(true) {}
}
// inferred type is "const neverReturns : () => never"

const maybe = Math.random() > .5 // type boolean
const maybeReturns = () => {
    while(maybe) {}
}
// inferred type is "const maybeReturns : () => void"
Enter fullscreen mode Exit fullscreen mode

Leveraging never for complex types

I mentioned before that I find never to be one of the more useful types when it comes to writing complex types. What is beneficial here is that never is pulled through nested type evaluations in a transitive way just as it is in transitive function invocations.

Let's say we have a type MailHostOf<T> which infers the host of a particular email address in a string value, so we want MailHostOf<"john@outlook.com"> to evalaute to "outlook". This is very easy to do with the infer-keyword in TypeScript. However, we also need to handle the case where types are passed that are no valid emails. Let's say we just map the mail host to null in those cases:

type MailHostOf<T> = T extends `${string}@${infer H}.com` ? H : null

type Domain = MailHostOf<"contact@lukasbach.com"> // type is "lukasbach"
type InvalidMail = MailHostOf<"this is no valid email"> // type is null

type AlternativeMail = `noreply@${Domain}.com` // type is "noreply@lukasbach.com"
type InvalidAlternative = `noreply@${InvalidMail}.com` // type is "noreply@null.com"
Enter fullscreen mode Exit fullscreen mode

This generally works fine, but as you can see, the type InvalidAlternative evaluates to "noreply@null.com" which is not what we want in this case. Type evaluation works different if we choose never as the returned type in those cases where a non-valid email is passed:

type MailHostOf<T> = T extends `${string}@${infer H}.com` ? H : never

type Domain = MailHostOf<"contact@lukasbach.com"> // type is "lukasbach"
type InvalidMail = MailHostOf<"this is no valid email"> // type is never

type AlternativeMail = `noreply@${Domain}.com` // type is "noreply@lukasbach.com"
type InvalidAlternative = `noreply@${InvalidMail}.com` // type is never
Enter fullscreen mode Exit fullscreen mode

This is more appropriate to what we expect.

Let's recall what the TypeScript handbook said about never: never is assignable to every type, but no other type is assignable to never. This is important here:

  • never being assignable to every type means that we can continue to refer to a type that potentially evaluates to never even further down the road of our complex type definitions.
  • no type being assignable to never means that, once we narrow down the type by providing values for generics and thus producing a certain never-type, the compiler will correctly alert us that a type was produced that cannot be used.

So even though for now our code is valid and no errors were produced, we will encounter errors once the types are used:

const validMail: AlternativeMail = "noreply@lukasbach.com"
// works

const invalidMail: InvalidAlternative = "" as any
// Compiler error: "Type 'any' is not assignable to type 'never'."
// nothing can be assigned here, not even any
Enter fullscreen mode Exit fullscreen mode

The type void

Whereas never is a type that is really useful for custom types, void is a type that is rather unlikely to be useful in many instances. It is the inferred return type of a function which returns nothing. This sounds very much like the definition of never, but recall that never is the return type of a function that literally never returns, i.e. an exception is thrown or the function is stuck in a loop, whereas void is the return type of a function which very much returns, it just does not return any value. You probably know the term from other programming languages like Java, where you are required to type a function to void in that case.

const neverFunc = () => {
    // type: () => never
    throw Error()
}
const voidFunc = () => {
    // type: () => void
}
Enter fullscreen mode Exit fullscreen mode

In JavaScript, the return value of a function that doesn't return anything is evaluated as undefined. As such, undefined is the only valid value that can be assigned to the type void (although, with strictNullChecks enabled, null can also be assigned):

const a: void = undefined // works
const b: void = true // type error
Enter fullscreen mode Exit fullscreen mode

However, void is special in one aspect: A function of type () => void can also return other things than undefined, even functions that never return are valid assignments.

type voidFunc = () => void
const returnsUndefined: voidFunc = () => undefined
const returnsTrue: voidFunc = () => true
const returnsString: voidFunc = () => "hello"
const neverReturns: voidFunc = () => {
    throw Error("sorry")
}
// All work, no type errors here
Enter fullscreen mode Exit fullscreen mode

If a function is typed as () => void, it is just that its return type is evaluated to void and thus cannot be used:

type voidFunc = () => void
const returnsString: voidFunc = () => "hello"
const returnValue = returnsString() // evaluates to "void"
Enter fullscreen mode Exit fullscreen mode

This very much looks counter intuitive at first glance - Why have that type in the first place it does not enforce the return type to be undefined? In practice, the useful implication of this is the ability to type a function such that its return type does not matter, and, more importantly, use a function with a specific return type in a situation where its return type is unimportant.

Common use cases are the ability to provide callbacks that do return a value to library functions that want a function without a return value.

const array = []
typeof array.push // (...items: any[]) => number
typeof array.forEach // (callbackfn: (value: any, ...) => void, thisArg?: any) => void
[1, 2, 3].forEach(num => array.push(num))
Enter fullscreen mode Exit fullscreen mode

In the final line, we have an inline forEach loop that calls the array.push function for every item in the [1, 2, 3] array. However, forEach requires callbacks with void as return value, whereas array.push returns a number (the length of the new array). So intuitively we would have a type clash here, but the final line is valid in JavaScript and works expected, so the void type is defined with this workaround in mind so that those cases are still valid.

The type unknown

Finally, we have the type unknown. It behaves similar to any as in it being assignable to every other type, except that the compiler forbids you to do anything with something that is unknown before (safely) casting it to something more specific. It serves as a safer alternative to any, which is okay to use in production (which is something not generally the case with any), even though some additional checks are necessary in this case.

const value: unknown = { user: "Lukas" }
// let's assume this comes from a REST call where we
// don't know the value at compile time

console.log(value.user) // error

if (typeof value === "object" && value !== null && "user" in value) {
    // the if-clause made an implicit cast
    console.log(value.user) // safe, no error
}
Enter fullscreen mode Exit fullscreen mode

Explicit casts can also be used with user-defined type guards:

type T = { user: string }
const isT = (val: any): val is T 
    => typeof value === "object" && value !== null && "user" in value

const value: unknown = { user: "Lukas" }

if (isT(value)) {
    console.log(value.user) // safe, no error
}
Enter fullscreen mode Exit fullscreen mode

Note that unknown and any are also the only legal explicit types for exceptions in catch-clauses, and again unknown is the safer way to use here:

// unsafe catch
try {
    maybeThrows()
} catch (e: any) {
    console.log(e.message)
}

// safe catch
try {
    maybeThrows()
} catch(e: unknown) {
    console.log(e.message)
    // error, e could be anything

    if (e instanceof Error) {
        console.log(e.message)
        // valid
    }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)