BattlefyBlogHistoryOpen menu
Close menuHistory

More on assertion function with string literals

Ronald Chen August 15th 2022

We've talked about refactoring lying TypeScript type assertions into real assertions before in the context of the DOM API. But there is a more common use-case with union of string literals.

An union of string literals is often use to describe configuration options. For example, Vite log levels are 'info' | 'warn' | 'error'. But what is the best way in TypeScript to convert a string into a type?

The problem

Let's say we are given a string and want to convert it into a LogLevel. We want to use the LogLevel in a switch statement.

type LogLevel = "info" | "warn" | "error";

function consumeLogLevel(level: LogLevel) {
  switch (level) {
    case "info":
      console.log("...use info");
      break;
    case "warn":
      console.log("...use warn");
      break;
    case "error":
      console.log("...use error");
      break;
    default:
      // should be impossible as TypeScript supports exhaustive switch
      console.log("...use default");
      break;
  }
}

Bad solution, type assertion

To convert a string into a LogLevel, the simplest way is to use type assertion. Type assertion is the syntax of the form value as Type. It is hard to Google for if you didn't know the term. Lots of people mistaken type assertion as "type casting".

The type assertion value as Type means is, "I, as the human, assure you, TypeScript, that value is a Type". TypeScript will complain if it is impossible for value to be a Type, for example such as true as string would never be allowed. But in this case TypeScript sees value is a string and believes the human made type assertion.

Continuing our example, here is what not to do.

// BAD type assertion
function typeAssertionLogLevel(value: string): LogLevel {
  return value as LogLevel;
}

const coercedLevel = typeAssertionLogLevel("debug");
// many lines of code
consumeLogLevel(coercedLevel); // fails late

We pass in an invalid log level "debug" and TypeScript happily believes it is a LogLevel. It is not until we use the invalid LogLevel does our code fail.

Also notice this cause TypeScript to break its promise of handling switch cases exhaustively. The previously impossible default case gets triggered. Our bad promise to TypeScript cause TypeScript in turn to break its promise back to us. But let's be clear here, we are at fault. We are the ones abusing TypeScript, not the other way around.

Type assertion is like using a sledgehammer to drive a nail. What is the more appropriate hammer?

Good solution, assertion function

Assertion function is more appropriate in this situation. The syntax is much more involved. (value: unknown) => asserts value is Type, means "I, as the human, assure you, TypeScript, that the body of this function will return void if the value is a Type, otherwise I will throw an error". That is a much more precise promise and requires the human to actually validate value.

What this looks like in code.

function assertLogLevel(value: unknown): asserts value is LogLevel {
  if (typeof value === "string" && !["info", "warn", "error"].includes(value)) {
    throw new Error(
      `expected log level of 'info', 'warn' or 'error', but got '${value}'`
    );
  }
}

const level = "debug";
assertLogLevel(level); // fails fast
// many lines of code
consumeLogLevel(level);

Now we detect the bad LogLevel early and the default case in consumeLogLevel is back to being impossible.

Full TypeScript playground

Best solution, Zod

While it is important to understand how TypeScript works, lots of what is described here is better implemented with Zod enum.

Do you enjoy your switch cases being exhaustive? You're in luck, Battlefy is hiring.

2022

Powered by
BATTLEFY