Typescript - key/property type guard
Asked Answered
T

5

19

Can I create a typeguard which asserts that a particular property exists (or has a specific type) in an object.

I.e

I have an interface Foo:

interface Foo {
    bar: string;
    baz: number;
    buzz?: string;
}

Now an object of type Foo will have an optional property buzz. How would I write a function which asserts that buzz exists: i.e

const item: Foo = getFooFromSomewhere();

if (!hasBuzz(item)) return;

const str: string = item.buzz; 

How would I implement hasBuzz()?. Something along the lines of a typeguard:

function hasBuzz(item: Foo): item.buzz is string {
    return typeof item.buzz === 'string'
}

Does something like this exist?

PS: I understand I can just do:

const item = getFooFromSomewhere();

if (typeof item.buzz === 'string') return;

const str: string = item.buzz; 

But my actual use-case requires me to have a separate function which asserts the existence of buzz.

Tisman answered 3/10, 2019 at 8:23 Comment(1)
Use "buzz" in item?Satellite
R
15

I don't like the existing answers here because they are all specific to checking a Foo object, but you can define a hasBuzz typeguard that will check any object to see if it has a buzz property.

interface Buzzable {
    buzz: string;
}

function hasBuzz<T extends {buzz?: any}>(obj: T): obj is T & Buzzable {
    return typeof obj.buzz === "string";
}

By using a generic T for the input and returning obj is T & Buzzable rather than just obj is Buzzable you won't lose any information about specific interfaces such as Foo when checking with hasBuzz.

If hasBuzz(item: Foo) is true, then typescript knows that the type of item is Foo & Buzzable. In this case that is the same as Required<Foo> since Foo has an optional buzz property, but you can check any object. hasBuzz({}) is perfectly valid, and should always return false.

Typescript Playground Link

Repurchase answered 1/10, 2020 at 21:31 Comment(0)
R
4

The point of a guard is to allow you to narrow down when you're not sure what the type is:

What might work in your case is:

interface Foo {
    bar: string;
    baz: number;
    buzz?: string;
}


function hasBuzz(item: Foo|Required<Foo>): item is Required<Foo> {
    return typeof item.buzz === 'string'
}

const f : Foo = {
    bar: 'a',
    baz: 1,
    buzz: 'x'
};


const str : string = f.buzz; // error

if (hasBuzz(f)) {
    const str2 : string = f.buzz; // works
}

Required is a helper type that given another type will return that type with all properties required (available since ts 2.8). This will narrow down your item variable as being of type Required<Foo>

Roop answered 3/10, 2019 at 8:43 Comment(0)
U
1

Here's a generic solution that will work for any type and any field.

type RequiredField<T, Key extends keyof T> = Omit<T, Key> & Required<Pick<T, Key>>;

function isFieldsDefined<T extends {}, U extends [keyof T, ...Array<keyof T>]>(
    obj: T, 
    keys: U
): obj is RequiredField<T, U[number]> {
    return keys.every((key) => key in obj);
}

isFieldsDefined takes 2 parameters:

  1. the object of type T
  2. a non empty array (one or more items) of properties that exists on interface T. keyof is T translates to bar | baz | buzz. This is great because if you tried to check for a field which doesn't exist on the interface (booz for instance), compilation will fail
  3. for every key, we check that the key exists in the object

The type guard used by this function is an interesting one. But before I explain it, there's a few things to know.

Omit is a utility that creates a new type without the specified properties. Example:

Omit<Foo, "bar"> === interface {baz: number, buzz?: string;}

Pick is a utility that creates a new type with the specified properties only. Example:

Pick<Foo, "bar"> === interface {bar: string;}

Required is a utility that creates a new type where all the properties are required. Example:

Required<Foo> === interface {bar: string; baz: number; buzz: string;}

Hence, RequiredField creates a new type where the specified fields are first omitted, then added back as non-undefineable. Below is how RequiredField<Foo, "buzz> transitions into the new type:

1. RequiredField<Foo, "buzz">
2. Omit<Foo, "buzz"> & Required<Pick<Foo, "buzz">>
3. {bar: string; baz: number;} & Required<{buzz?: string}>
4. {bar: string; baz: number;} & {buzz: string}
5. {bar: string; baz: number; buzz: string}

Now that we know how RequiredField works, we may have a second look at isFieldsDefined type guard obj is RequiredField<T, U[number]>.

Since type U is an array of properties of T, U[number] will be a union type where each member corresponds with a value at an index in the array. Example:

type Fields = ["bar", "baz", "buzz"];
Fields[number] === "bar" | "baz" | "buzz"

Therefore, here's a break down of the type guard for isFieldsDefined(item, ["buzz"]):

obj is RequiredField<Foo, ["buzz"][number]>
obj is RequiredField<Foo, "buzz">
obj is {bar: string; baz: number; buzz: string;}

You may check that multiple fields are defined too: isFieldsDefined(item, ["buzz", "bar"]);

Uniat answered 23/11, 2023 at 0:27 Comment(0)
P
0

This should work:

function hasBuzz(item: Foo): item is Foo & Required<Pick<Foo, 'buzz'>> {
    return !!item.buzz
}
Piccadilly answered 12/12, 2019 at 14:38 Comment(0)
R
-1

You can use definite assignment assertions when you reference the property.

function hasBuzz(item: Foo): string {
    return item.buzz!;
}

If you want to treat the object as definitely having buzz further on in the code, you can narrow the type:

interface DefinitelyBuzzed extends Foo {
    buzz: string;
}
Rotenone answered 3/10, 2019 at 8:33 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.