2
votes

I have a function which checks if an object is a "plain object" - that is, it's not an instance of any class, an object initialized like let x = { a: 1 }; I want to add a type guard to this function, to assert that the checked value matches an interface ([key: string]: any).

For some reason, the function does not narrow the type. Perhaps it is because "plain object" as I defined it and [key: string]: any are not the same thing at all (e.g. an instance of a class could also match [key: string]: any). Nevertheless, I want my isPlainObject() function to assert that if successful, the value matches [key: string]: any).

This is what I tried in the playground. I also included a "handbook" example to check for a specific property - which works.

Playground

interface IndexedObject {
    [key: string]: any
}

interface FooObject {
    foo: "bar"
}

// This works - an example from the handbook
const isFooObject = (value: any): value is FooObject => (
    typeof value === 'object'
    && value !== null
    && typeof (value as FooObject).foo !== 'undefined'
);

/**
 * Check if the value is a "plain" JavaScript object initialized
 * with { } (not instance of any class)
 * 
 * In addition to the runtime check, this function should assert that the 
 * argument matches IndexedObject interface
 * 
 */
const isPlainObject = (value: any): value is IndexedObject => (
    typeof value === 'object'
    && value !== null
    && value.constructor === Object
    && Object.getPrototypeOf(value) === Object.prototype
);


function test<T>(value: T): T {

    if (isFooObject(value)) {
        const x = value; // T & FooObject - it works
    }

    if (isPlainObject(value)) {
        const x = value; // T - doesn't work
    }

    return value;

}

Update1:

I think I'm not going to use a type guard here, as the IndexedObject is too broad and can produce unexpected results. This would tell TypeScript that otherwise the value never matches IndexedObject - which is not desirable. I will use inline assertions instead.

Unless it is possible to define a type that would match "plain object"?

Nevertheless, I'm still curious why the type guard doesn't work. Also, if I return the value inside if block, after the if block the type will be never - playground 2

1
I think I should not be using a type guard here and instead, do inline type assertion. IndexedObject interface is too broad for this function and can result in unexpected things - e.g. if it actually worked, TypeScript would assume that after the if { } the type cannot match IndexedObject - which wouldn't be true here. - Maciej Krawczyk
Nevertheless, I'm still puzzled on why this does not work, and I have just tested an even edgier case - where if I return the value within the if { } block, after the block, the typeof value will be never. New playground case: shorturl.at/abqEH - Maciej Krawczyk
This might be a bug. It seems like it just ignores the type because the properties are typed as any, which might not register as "meaningful". If you change the property type to number for example, it will register. Btw, types like that can be written using the helper Record (here Record<string, any>). - H.B.

1 Answers

1
votes

Not really an answer, but the fact that generics are involved appears to make it swallow the type. As noted in my comment, this seems like a bug.

function testTypeGuard<T>(value: unknown): value is T
{
    return true; // Lies
}

function genericFunction<T>(value: T)
{
    if (testTypeGuard<Record<string, any>>(value))
        console.log(value); // T
    if (testTypeGuard<Record<number, any>>(value))
        console.log(value); // T
    if (testTypeGuard<Record<any, string>>(value))
        console.log(value); // T & Record<any, string>
    if (testTypeGuard<Record<any, number>>(value))
        console.log(value); // T & Record<any, number>
    if (testTypeGuard<Record<string, string>>(value))
        console.log(value); // T & Record<string, string>
    if (testTypeGuard<Record<number, string>>(value))
        console.log(value); // T & Record<number, string>
}

// No generics:
const value: unknown = {};
if (testTypeGuard<Record<string, any>>(value))
    console.log(value); // Record<string, any>
if (testTypeGuard<Record<number, any>>(value))
    console.log(value); // Record<number, any>
if (testTypeGuard<Record<any, string>>(value))
    console.log(value); // Record<any, string>
if (testTypeGuard<Record<any, number>>(value))
    console.log(value); // Record<any, number>
if (testTypeGuard<Record<string, string>>(value))
    console.log(value); // Record<string, string>
if (testTypeGuard<Record<number, string>>(value))
    console.log(value); // Record<number, string>