TypeScript allows programmers to define type guards so that programmers have more control over the dynamic nature of the typing of JavaScript. Since type guards are completely defined by the programmer, they also evade type checking from the TypeScript compiler: Type checking won’t catch any error if the type guard doesn’t correctly determine the type. This can be caused by typos, updating the type without updating the type guard, etc.

For example, consider the following interface and its well-written type guard:

interface DwarfPlanet {
  name: string;
  mass: number;
}

// type guard
function isDwarfPlanet(arg: unknown): arg is DwarfPlanet {
  return (
    typeof arg === "object" &&
    arg !== null &&
    "name" in arg &&
    typeof arg.name === "string" &&
    "mass" in arg &&
    typeof arg.mass === "number"
  );
}

It can be used like this:

function parseDwarfPlanetFromString(content: string): DwarfPlanet {
  const obj = JSON.parse(content); // obj is of type "any"
  if (!isDwarfPlanet(obj)) {
    throw new Error("Not a dwarf planet!");
  }
  return obj; // obj has type DwarfPlanet
}

So far, they seem to work well. But what if a team member unfamiliar with the code adds another property description: string? The TypeScript doesn’t complain at all! This is because TypeScript doesn’t check whether the user-defined type guard determines the type correctly or not.

Is there a safer alternative to type guards? This post discusses one alternative, which we refer to as the type extractor.

Introducing the Type Extractor

Continuing the previous example, instead of creating a type guard isDwarfPlanet, we create a function to “extract” the data structure defined by DwarfPlanet:

function extractDwarfPlanet(arg: unknown): DwarfPlanet | null {
  if (
    typeof arg === "object" &&
    arg !== null &&
    "name" in arg &&
    typeof arg.name === "string" &&
    "mass" in arg &&
    typeof arg.mass === "number"
  ) {
    return {
      name: arg.name,
      mass: arg.mass,
    };
  }

  return null;
}

This function takes an argument with an unknown type and returns the properties in DwarfPlanet if arg satisfies the requirement of DwarfPlanet, or null otherwise. We refer to this kind of functions as type extractors. It can be used similarly to a type guard:

function parseDwarfPlanetFromString(content: string): DwarfPlanet {
  const obj = JSON.parse(content); // obj is of type "any"
  const dwarfPlanet = extractDwarfPlanet(obj);
  if (dwarfPlanet === null) {
    throw new Error("Not a dwarf planet!");
  }
  return dwarfPlanet; // dwarfPlanet has type DwarfPlanet
}

Why not Returning arg?

Due to a limitation in TypeScript, TypeScript does not narrow types of parent objects. Returning arg instead of {name: arg.name, mass: arg.mass} results in a TypeScript error:

detailed error message

Type object & Record<"name", unknown> & Record<"mass", unknown> is not assignable to type DwarfPlanet | null.

Type object & Record<"name", unknown> & Record<"mass", unknown> is not assignable to type DwarfPlanet.

  Types of property name are incompatible.

     Type unknown is not assignable to type string.

How Type Extractors Help Maintain Type Safety

A type extractor will cause the TypeScript to complain if there is an update in the type without corresponding changes in the type extractor:

  • If we add a new property to DwarfPlanet, such as description: string, the TypeScript compiler will complain:

    Property description is missing in type { name: string; mass: number; } but required in type DwarfPlanet.

  • If we remove a property from DwarfPlanet, such as mass, the TypeScript compiler will complain:

    Object literal may only specify known properties, and mass does not exist in type DwarfPlanet.

  • If we change the type of a property in DwarfPlanet, such as changing mass to a string, the TypeScript compiler will complain:

    Type number is not assignable to type string.

Limitations of Type Extractors

While type extractors offer better type safety than type guards, they also lose the flexibility of type guards and bear some limitations:

  • A type extractor fails to cause type checking to fail if a property’s type is widened. In the example above, if we widen the type of mass to number | string or number | undefined (i.e., making a property optional), the type checking still passes. This is particularly concerning when expanding a property that has a union type. For example,

    interface Planet {
      name: "Earth" | "Mars";
    }
    

    When we decide to add a new planet "Venus", the TypeScript compiler won’t complain. Therefore, in this case, it is recommended to use a literal array to maintain the union to work around. See Union of Literals for a detailed example.

  • A type extractor always creates a new object. This may be a performance concern if the type is big.

  • A type extractor loses extra properties not present in the type. In the example above, if arg contains properties other than name and mass, those properties are discarded. This can be addressed by letting the extractor return

    return {
      ...arg,
      name: arg.name,
      mass: arg.mass,
    };
    

    But this may cause a performance issue if arg is big.

Examples for Some Common Scenarios

Nested Objects

Consider the type:

interface DwarfPlanet {
  name: string;
  mass: number;
  orbit: {
    period: number;
    distanceToSun: number;
  };
}

The type extractor would look like:

function extractDwarfPlanet(arg: unknown): DwarfPlanet | null {
  if (
    typeof arg === "object" &&
    arg !== null &&
    "name" in arg &&
    typeof arg.name === "string" &&
    "mass" in arg &&
    typeof arg.mass === "number" &&
    "orbit" in arg &&
    typeof arg.orbit === "object" &&
    arg.orbit !== null &&
    "period" in arg.orbit &&
    typeof arg.orbit.period === "number" &&
    "distanceToSun" in arg.orbit &&
    typeof arg.orbit.distanceToSun === "number"
  ) {
    return {
      name: arg.name,
      mass: arg.mass,
      orbit: {
        period: arg.orbit.period,
        distanceToSun: arg.orbit.distanceToSun,
      },
    };
  }

  return null;
}

Optional Properties

Consider the type:

interface DwarfPlanet {
  name: string;
  mass?: number;
}

The type extractor would look like:

function extractDwarfPlanet(arg: unknown): DwarfPlanet | null {
  // Required properties
  if (!(typeof arg === "object" && arg !== null && "name" in arg && typeof arg.name === "string")) {
    return null;
  }
  let result: DwarfPlanet = {
    name: arg.name,
  };

  // Optional properties
  if ("mass" in arg && arg.mass !== undefined) {
    if (typeof arg.mass === "number") {
      result.mass = arg.mass;
    }
  }

  return result;
}

Type Extractor of a Union of Types

Let’s say we already have the type extractors for the types DwarfPlanet, Moon, and Asteroid. Consider the union of them:

type SubPlanet = DwarfPlanet | Moon | Asteroid;

The type extractor would look like:

function extractSubPlanet(arg: unknown): SubPlanet | null {
  return extractDwarfPlanet(arg) ?? extractMoon(arg) ?? extractAsteroid(arg);
}

Union of Literals

Sometimes a property is a union of literals:

interface Planet {
  name: "Earth" | "Mars";
}

A basic type extractor would look like:

function extractPlanet(arg: unknown): Planet | null {
  if (
    typeof arg === "object" &&
    arg !== null &&
    "name" in arg &&
    (arg.name === "Earth" || arg.name === "Mars")
  ) {
    return {
      name: arg.name,
    };
  }

  return null;
}

As discussed in Limitations of Type Extractors, adding a new string literal to name won’t trigger any failure in type checking while the type extractor is outdated. With the help of isInArray, we can move the definition of the union type to an array and safely modify the array to update the definition of the type and the type extractor at the same time:

// Utility function to determine whether an element is in a given array.
function isInArray<T, Element extends T>(
  element: T,
  array: readonly Element[],
): element is Element {
  const arrayT: readonly T[] = array;
  return arrayT.includes(element);
}

const planetNames = ["Earth", "Mars"] as const;
interface Planet {
  name: planetNames[number];
}

function extractPlanet(arg: unknown): Planet | null {
  if (
    typeof arg === "object" &&
    arg !== null &&
    "name" in arg &&
    isInArray(arg.name, planetNames)
  ) {
    return {
      name: arg.name,
    };
  }

  return null;
}

Now, if we would like to add "Venus" to planet names, we can simply update the definition of planetNames:

const planetNames = ["Earth", "Mars", "Venus"] as const;

Uses in Other Contexts

Besides the examples above, type extractors can also be used with JSON.parse.

Conclusion

Type guards rely on the programmer to be correctly defined. They are prone to errors, such as typos and outdatedness caused by future changes in the underlying types.

To address this issue, in this post, we introduced the type extractor, which may be an appropriate safer alternative to a type guard in many scenarios. We then discussed how type extractors help maintain type safety, their limitations, and some examples for some common scenarios.