Type guard to check if object properties are defined

Oluwafemi Shobande
Typescript Tidbits
Published in
3 min readJan 8, 2024

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.

--

--