User-defined type guards in TypeScript

2021-03-09

Decided to write this blog post because I couldn’t find any documentation or online examples like the one I’m sharing here. Every example I’ve seen the user-defined type guard is used to replace the whole type definition and not specific properties.

Here’s the code without type guard.

interface Config {
  foo: string;
  bar: string;
}

class Thing {
  name: string;
  config?: Config;

  constructor(name: string, config?: Config) {
    this.name = name;
    this.config = config;
  }

  // 👀 returning boolean, will change on next example
  hasConfig(): boolean {
    if (this.config) {
      return true;
    }
    return false;
  }
}

let firstThing = new Thing("Apple", { foo:"one", bar: "two" });

if (firstThing.hasConfig()) {
  // #1 - Here firstThing.config is `Config | undefined`
  console.log(firstThing.config.foo);
}

if (firstThing.config) {
  // #2 - Here firstThing.config is `Config`
  console.log(firstThing.config.foo);
}

On #1 firstThing.config. is Config | undefined even though we know it can’t be undefined because our hasConfig method made sure the value is truthy.

On #2 firstThing.config is Config as expected.

So in order to help TypeScript detect the correct type we can use a type predicate as the return type.

interface Config {
  foo: string;
  bar: string;
}

class Thing {
  name: string;
  config?: Config;

  constructor(name: string, config?: Config) {
    this.name = name;
    this.config = config;
  }

  // 👀 notice the return type
  hasConfig(): this is { config: Config } {
    if (this.config) {
      return true;
    }
    return false;
  }
}

let firstThing = new Thing("Apple", { foo:"one", bar: "two" });

if (firstThing.hasConfig()) {
  // Now firstThing.config is `Config`
  console.log(firstThing.config.foo);
}

Now firstThing.config is Config as expected. Have in mind that even though the type guard says this is { config: Config }, TypeScript will merge this type with the original type definition of Thing.

Thanks to the nice folks at the TypeScript Discord who helped me solve this issue. Hope this helps more people.