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:
Type Type   Types of property     Type detailed error message
object & Record<"name", unknown> & Record<"mass", unknown>
is not assignable to type
DwarfPlanet | null
.object & Record<"name", unknown> & Record<"mass", unknown>
is not assignable to type
DwarfPlanet
.name
are incompatible.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 asdescription: string
, the TypeScript compiler will complain:Property
description
is missing in type{ name: string; mass: number; }
but required in typeDwarfPlanet
. - If we remove a property from
DwarfPlanet
, such asmass
, the TypeScript compiler will complain:Object literal may only specify known properties, and
mass
does not exist in typeDwarfPlanet
. - If we change the type of a property in
DwarfPlanet
, such as changingmass
to a string, the TypeScript compiler will complain:Type
number
is not assignable to typestring
.
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
tonumber | string
ornumber | 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 thanname
andmass
, those properties are discarded. This can be addressed by letting the extractor returnreturn { ...arg, name: arg.name, mass: arg.mass, };
But this may cause a performance issue if
arg
is big.
Examples for Some Common Scenarios
Consider the type: The type extractor would look like:Nested Objects
interface DwarfPlanet {
name: string;
mass: number;
orbit: {
period: number;
distanceToSun: number;
};
}
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;
}
Consider the type: The type extractor would look like:Optional Properties
interface DwarfPlanet {
name: string;
mass?: number;
}
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;
}
Let’s say we already have the type extractors for the types The type extractor would look like:Type Extractor of a Union of Types
DwarfPlanet
, Moon
, and Asteroid
.
Consider the union of them:type SubPlanet = DwarfPlanet | Moon | Asteroid;
function extractSubPlanet(arg: unknown): SubPlanet | null {
return extractDwarfPlanet(arg) ?? extractMoon(arg) ?? extractAsteroid(arg);
}
Sometimes a property is a union of literals: A basic type extractor would look like: As discussed in Limitations of Type Extractors, adding a new string literal to
Now, if we would like to add Union of Literals
interface Planet {
name: "Earth" | "Mars";
}
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;
}
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;
}
"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.