Type guard to check if object properties are defined
Suppose we have a ScoreSheet
interface and a function which checks if some score sheet belongs to a student from the department of science:
interface ScoreSheet {
mathematics: number;
physics?: number;
music?: number;
}
function isScienceStudent(scoreSheet: ScoreSheet): boolean {
return scoreSheet.physics !== undefined;
}
From the implementation of isScienceStudent
, we know for sure that if true
is returned, scoreSheet.physics
is defined, however, the code below doesn’t compile:
const scoreSheet: ScoreSheet = {mathematics: 93, physics: 55, music: 82};
if (isScienceStudent(scoreSheet)) {
const physicsScore: number = scoreSheet.physics; //❌
}
This is because, scoreSheet.physics
is still of type number | undefined
as opposed the expected type of number
.
Type Guards are an amazing mechanism for informing the compiler of narrowed types. In our case, if the scoresheet belongs to a science student, we should inform the compiler that the type of scoreSheet.physics
has been narrowed from number | undefined
to number
. The rest of this write-up assumes basic understanding of Type Guards, so if this is your first rodeo, I suggest reading the docs.
RequiredField Type
type RequiredField<T, Field extends keyof T> =
Omit<T, Field> & Required<Pick<T, Field>>;
RequiredField
is a utility that takes some type T
and returns a new type where Field
is required, but all other properties remain unchanged. There’s a few things to unpack in the implementation detail.
Omit
is an in-built utility that creates a new type without the specified properties:
type OmitMathematics = Omit<ScoreSheet, "mathematics">;
// is equivalent to
type OmitMathematics = {
physics?: number;
music?: number;
};
Pick
is an in-built utility that creates a new type with the specified properties only:
type PickMathematics = Pick<ScoreSheet, "mathematics">;
// is equivalent to
type PickMathematics = {
mathematics: number;
};
Required
is an in-built utility that creates a new type where all the properties are required. Example:
type AllSubjectsRequired = Required<ScoreSheet>;
// is equivalent to
type AllSubjectsRequired = {
mathematics: number;
physics: number;
music: number;
};
Hence, RequiredField
creates a new type where the specified fields are first omitted from T
, then added back to T
as non-undefineable. For clarity, here’s are breakdown of RequiredField<ScoreSheet, "physics">
:
1. RequiredField<ScoreSheet, "physics">
2. Omit<ScoreSheet, "physics"> & Required<Pick<ScoreSheet, "physics">>
3. {
mathematics: number;
music?: number;
} & Required<{physics?: number}>
4. {
mathematics: number;
music?: number;
} & {physics: number}
5. {
mathematics: number;
music?: number;
physics: number
}
Hence, isScienceStudent
can be converted into a type guard as show below, so that within the true
branch of the if statement, scoreSheet.physics
will have type number
.
function isScienceStudent(
scoreSheet: ScoreSheet
): scoreSheet is RequiredField<ScoreSheet, "physics"> {
return scoreSheet.physics !== undefined;
}
if (isScienceStudent(scoreSheet)) {
const physicsScore: number = scoreSheet.physics; //✅︎
}
Generic areFieldsDefined Type Guard
As a bonus, here’s generalised implementation of the guard from the previous section.
function areFieldsDefined<T extends {}, U extends Array<keyof T>>(
obj: T,
fields: U
): obj is RequiredField<T, U[number]> {
return fields.every((field) => obj[field] !== undefined);
}
areFieldsDefined
takes 2 parameters:
1. an object of type T
2. an array of properties of T
. For instance, if T
is type ScoreSheet
, keyof T
translates to "mathematics" | "physics" | "music"
. Hence, U
is an array where each value can be any one of mathematics, physics or music. This is great because if you tried to check for a property which doesn’t exist on the T
, say biology
, compilation will fail.
The body of this guard is pretty straightforward; it returns true if all specified fields are defined, else false is returned. The interesting part of this guard lies in this portion obj is RequiredField<T, U[number]>
.
Since type U
is an array of properties of T
, U[number]
will be a union of those properties. Example:
const names = ["alice", "bob", "dylan"];
type Names = names[number];
// is equivalent to
type Names = "alice" | "bob" | "dylan";
Now we have a generalised guard that can check that any arbitrary number properties of some type are defined. For instance, areFieldsDefined(scoreSheet, ["physics", "music"])
checks that the score sheet contains both physics and music.
1. RequiredField<ScoreSheet, ["physics", "music"][number]>
2. RequiredField<ScoreSheet, "physics" | "music">
3. {
mathematics: number;
physics: number;
music: number;
}
Hence, the custom isScienceStudent
guard may be replaced with the generic areFieldsDefined
guard:
if (areFieldsDefined(scoreSheet, ["physics"])) {
const physicsScore: number = scoreSheet.physics; //✅︎
}
If you like this content, kindly subscribe to this publication as I will be sharing many more neat tricks. Also here’s my Twitter, if you’d like to reach out.