We use cookies to enhance your experience on the site
CodeWorlds

Type Narrowing and Type Guards

"In Jurassic Park, it's not enough to know you have a dinosaur in front of you," says Robert Muldoon, the park's chief game warden. "You need to know exactly WHETHER it's a predator, WHETHER it's a herbivore, WHETHER it flies, WHETHER it swims. The security protocol you activate depends on that. One wrong assumption and you have a catastrophe."

Type narrowing in TypeScript works similarly - it's the process of narrowing a broad type to a more specific one. When a variable has type

string | number
, TypeScript doesn't know whether it's text or a number. With type guards, we can "narrow" the type so the compiler knows exactly what it's dealing with. This is a fundamental technique for writing safe TypeScript code.

The typeof Operator as a Type Guard

The simplest type guard is

typeof
- a JavaScript operator that TypeScript understands and uses for type narrowing. When you check the type of a variable using
typeof
, the compiler automatically knows what type the variable has inside the
if
block.

1// Function accepting different types of dinosaur data
2function formatDinosaurData(value: string | number | boolean): string {
3  if (typeof value === "string") {
4    // TypeScript knows that value is of type string here
5    return value.toUpperCase();
6  }
7  if (typeof value === "number") {
8    // TypeScript knows that value is of type number here
9    return value.toFixed(2) + " kg";
10  }
11  // TypeScript knows that value is of type boolean here
12  return value ? "Active" : "Inactive";
13}
14
15console.log(formatDinosaurData("Tyrannosaurus Rex")); // "TYRANNOSAURUS REX"
16console.log(formatDinosaurData(7500));                 // "7500.00 kg"
17console.log(formatDinosaurData(true));                 // "Active"

In the above example, TypeScript automatically narrows the type of

value
in each
if
block. After checking
typeof value === "string"
, the compiler knows that inside that block
value
is a string, so you can safely call
.toUpperCase()
. This works with types
"string"
,
"number"
,
"boolean"
,
"bigint"
,
"symbol"
,
"undefined"
,
"object"
, and
"function"
.

The instanceof Operator

When working with classes,

instanceof
allows you to check whether an object is an instance of a specific class. TypeScript automatically narrows the type after such a check.

1class Carnivore {
2  name: string;
3  biteForce: number;
4
5  constructor(name: string, biteForce: number) {
6    this.name = name;
7    this.biteForce = biteForce;
8  }
9
10  hunt(): string {
11    return `${this.name} hunts with bite force of ${this.biteForce}N!`;
12  }
13}
14
15class Herbivore {
16  name: string;
17  favoriteFood: string;
18
19  constructor(name: string, favoriteFood: string) {
20    this.name = name;
21    this.favoriteFood = favoriteFood;
22  }
23
24  graze(): string {
25    return `${this.name} spokojnie zjada ${this.favoriteFood}`;
26  }
27}
28
29// Function handling both dinosaur types
30function handleDinosaur(dino: Carnivore | Herbivore): string {
31  if (dino instanceof Carnivore) {
32    // TypeScript knows that dino is Carnivore here
33    return dino.hunt(); // We have access to hunt() and biteForce
34  }
35  // TypeScript knows that dino is Herbivore here
36  return dino.graze(); // We have access to graze() and favoriteFood
37}
38
39const rex = new Carnivore("T-Rex", 12800);
40const trike = new Herbivore("Triceratops", "Paprocie");
41
42console.log(handleDinosaur(rex));   // "T-Rex hunts with bite force of 12800N!"
43console.log(handleDinosaur(trike)); // "Triceratops spokojnie zjada Paprocie"

The in Operator

The

in
operator checks whether an object has a given property. TypeScript uses this to narrow types in union types.

1interface FlyingDinosaur {
2  name: string;
3  maxAltitude: number;
4  wingspan: number;
5}
6
7interface SwimmingDinosaur {
8  name: string;
9  maxDepth: number;
10  swimSpeed: number;
11}
12
13interface LandDinosaur {
14  name: string;
15  runSpeed: number;
16  weight: number;
17}
18
19type Dinosaur = FlyingDinosaur | SwimmingDinosaur | LandDinosaur;
20
21function describeDinosaur(dino: Dinosaur): string {
22  if ("maxAltitude" in dino) {
23    // TypeScript knows this is FlyingDinosaur
24    return `${dino.name} flies up to altitude of ${dino.maxAltitude}m, wingspan: ${dino.wingspan}m`;
25  }
26  if ("maxDepth" in dino) {
27    // TypeScript knows this is SwimmingDinosaur
28    return `${dino.name} dives to ${dino.maxDepth}m, speed: ${dino.swimSpeed} km/h`;
29  }
30  // TypeScript knows this is LandDinosaur
31  return `${dino.name} runs at speed of ${dino.runSpeed} km/h, weighs ${dino.weight} kg`;
32}
33
34const pteranodon: FlyingDinosaur = { name: "Pteranodon", maxAltitude: 500, wingspan: 7 };
35const mosasaurus: SwimmingDinosaur = { name: "Mosasaurus", maxDepth: 200, swimSpeed: 48 };
36const velociraptor: LandDinosaur = { name: "Velociraptor", runSpeed: 64, weight: 15 };
37
38console.log(describeDinosaur(pteranodon));
39console.log(describeDinosaur(mosasaurus));
40console.log(describeDinosaur(velociraptor));

The

in
operator is particularly useful when working with interfaces (not classes), because
instanceof
only works with classes, while
in
simply checks for the presence of a property.

Custom Type Guard Functions (the is Keyword)

You can create your own type guard functions that return the special type

parameterName is Type
. This way TypeScript understands that after a positive check, the variable has a specific type.

1interface DinoEgg {
2  species: string;
3  weight: number;
4  incubationDays: number;
5}
6
7interface HatchedDino {
8  species: string;
9  weight: number;
10  age: number;
11  enclosureId: string;
12}
13
14// Custom type guard - function returns "specimen is HatchedDino"
15function isHatched(specimen: DinoEgg | HatchedDino): specimen is HatchedDino {
16  return "age" in specimen && "enclosureId" in specimen;
17}
18
19function processSpecimen(specimen: DinoEgg | HatchedDino): string {
20  if (isHatched(specimen)) {
21    // TypeScript knows that specimen is HatchedDino here
22    return `${specimen.species} - wiek: ${specimen.age} dni, zagroda: ${specimen.enclosureId}`;
23  }
24  // TypeScript knows that specimen is DinoEgg here
25  return `Jajo ${specimen.species} - inkubacja: ${specimen.incubationDays} dni`;
26}
27
28// More advanced type guard with validation
29function isCarnivoreData(data: unknown): data is { species: string; biteForce: number; diet: "carnivore" } {
30  return (
31    typeof data === "object" &&
32    data !== null &&
33    "species" in data &&
34    "biteForce" in data &&
35    "diet" in data &&
36    (data as any).diet === "carnivore"
37  );
38}
39
40// Usage with an unknown data source (e.g., API)
41function processParkData(rawData: unknown): string {
42  if (isCarnivoreData(rawData)) {
43    // TypeScript wie, that rawData ma pola species, biteForce, diet
44    return `Predator: ${rawData.species}, force: ${rawData.biteForce}N`;
45  }
46  return "Nieznany typ danych";
47}
48
49console.log(processParkData({ species: "T-Rex", biteForce: 12800, diet: "carnivore" }));
50console.log(processParkData({ name: "Triceratops" }));

Custom type guards are particularly useful when:

  1. The checking logic is complex and you want to extract it into a separate function
  2. You're working with data from external sources (API, JSON files), whose type is
    unknown
  3. You want to create reusable validation tools

Discriminated Unions and Exhaustive Checks

Discriminated unions is a pattern where each type in the union has a common "discriminating" field (tag) that uniquely identifies the type. TypeScript excels at narrowing types based on this field.

1// Each type has a "kind" field as the discriminator
2interface SecurityAlert {
3  kind: "security";
4  zone: string;
5  threatLevel: number;
6  dinosaurId: string;
7}
8
9interface MedicalAlert {
10  kind: "medical";
11  dinosaurId: string;
12  symptoms: string[];
13  veterinarianRequired: boolean;
14}
15
16interface SystemAlert {
17  kind: "system";
18  component: string;
19  errorCode: number;
20  message: string;
21}
22
23type ParkAlert = SecurityAlert | MedicalAlert | SystemAlert;
24
25function handleAlert(alert: ParkAlert): string {
26  switch (alert.kind) {
27    case "security":
28      // TypeScript knows this is SecurityAlert
29      return `SECURITY ALARM! Zone: ${alert.zone}, Threat: ${alert.threatLevel}/10, Dinosaur: ${alert.dinosaurId}`;
30    case "medical":
31      // TypeScript knows this is MedicalAlert
32      return `ALERT MEDYCZNY! Dinosaur: ${alert.dinosaurId}, Objawy: ${alert.symptoms.join(", ")}`;
33    case "system":
34      // TypeScript knows this is SystemAlert
35      return `AWARIA SYSTEMU! Komponent: ${alert.component}, Kod: ${alert.errorCode}`;
36  }
37}

Exhaustive Check with the never Type

The

never
type in TypeScript means "a value that will never occur". We can use it to check whether we've handled ALL variants of a discriminated union. If we add a new variant and forget to handle it, TypeScript will throw a compilation error.

1// Exhaustive check - TypeScript will warn us if we miss any variant
2function handleAlertExhaustive(alert: ParkAlert): string {
3  switch (alert.kind) {
4    case "security":
5      return `Security: ${alert.zone}`;
6    case "medical":
7      return `Medyczny: ${alert.dinosaurId}`;
8    case "system":
9      return `System: ${alert.component}`;
10    default:
11      // If we add a new alert type and forget to handle it,
12      // TypeScript will report an error, because alert won't be of type never
13      const exhaustiveCheck: never = alert;
14      return exhaustiveCheck;
15  }
16}
17
18// Practical helper for exhaustive check
19function assertNever(value: never, message: string = "Unhandled variant"): never {
20  throw new Error(`${message}: ${JSON.stringify(value)}`);
21}

Exhaustive check is a powerful technique - when you add e.g.

WeatherAlert
to the
ParkAlert
union, the compiler will IMMEDIATELY point out all places in the code where handling of the new variant is missing. It's like an early warning system in Jurassic Park - it doesn't let you overlook any threat.

Combining Type Guards - Practical Example

Below is a more elaborate example combining different type narrowing techniques in one system:

1// Laboratory sample classification system
2type SampleStatus = "collected" | "analyzing" | "completed" | "contaminated";
3
4interface BaseSample {
5  id: string;
6  collectedAt: Date;
7  status: SampleStatus;
8}
9
10interface DNASample extends BaseSample {
11  type: "dna";
12  species: string;
13  purity: number;
14  sequenceLength: number;
15}
16
17interface BloodSample extends BaseSample {
18  type: "blood";
19  species: string;
20  volumeMl: number;
21  pH: number;
22}
23
24interface FossilSample extends BaseSample {
25  type: "fossil";
26  era: string;
27  estimatedAge: number;
28}
29
30type LabSample = DNASample | BloodSample | FossilSample;
31
32// Custom type guard
33function isDNASample(sample: LabSample): sample is DNASample {
34  return sample.type === "dna";
35}
36
37// Checking status using typeof and custom guard
38function canProcess(sample: LabSample): boolean {
39  return sample.status === "collected" || sample.status === "analyzing";
40}
41
42// Main processing function - combines different techniques
43function processLabSample(sample: LabSample): string {
44  // Discriminated union narrowing
45  switch (sample.type) {
46    case "dna":
47      if (sample.purity < 50) {
48        return `DNA Sample ${sample.id}: purity too low (${sample.purity}%)`;
49      }
50      return `DNA Analysis ${sample.species}: ${sample.sequenceLength} base pairs, purity ${sample.purity}%`;
51
52    case "blood":
53      const phStatus = sample.pH >= 7.0 && sample.pH <= 7.6 ? "normal" : "OUT OF RANGE";
54      return `Analiza krwi ${sample.species}: ${sample.volumeMl}ml, pH ${sample.pH} (${phStatus})`;
55
56    case "fossil":
57      return `Fossil from era ${sample.era}: estimated age ${sample.estimatedAge} million years`;
58
59    default:
60      const _exhaustive: never = sample;
61      return _exhaustive;
62  }
63}
64
65// Test
66const dnaSample: DNASample = {
67  id: "DNA-042", type: "dna", collectedAt: new Date(),
68  status: "collected", species: "Velociraptor", purity: 87, sequenceLength: 75000000
69};
70
71const bloodSample: BloodSample = {
72  id: "BLD-015", type: "blood", collectedAt: new Date(),
73  status: "analyzing", species: "Triceratops", volumeMl: 120, pH: 7.2
74};
75
76const fossilSample: FossilSample = {
77  id: "FOS-003", type: "fossil", collectedAt: new Date(),
78  status: "completed", era: "Kreda", estimatedAge: 68
79};
80
81console.log(processLabSample(dnaSample));
82console.log(processLabSample(bloodSample));
83console.log(processLabSample(fossilSample));

Summary

Type narrowing and type guards are fundamental TypeScript tools that allow you to write safe code while maintaining the flexibility of union types:

  1. typeof - narrows primitive types (
    string
    ,
    number
    ,
    boolean
    )
  2. instanceof - narrows class types
  3. in - narrows types based on the presence of a property in an object
  4. Custom type guards (is) - you define your own type-checking functions
  5. Discriminated unions - a shared "tag" field unambiguously identifies a variant
  6. Exhaustive check with never - the compiler ensures you've handled all variants

As Robert Muldoon says: "Species identification is the first step of the security protocol. Similarly, type narrowing is the first step to safe code - never assume what you're dealing with. Check, narrow the type, and act with confidence."

Go to CodeWorlds