Dai
Dai

Reputation: 155055

What is the (correct) idiomatic alternative to `any` with `typeof` when writing type-guards?

I'm currently writing client-side error-handling code in TypeScript, in particular, I'm writing an event-listener for window.error, which receives an ErrorEvent object which in-turn has a member property named error which in practice could be anything depending on various circumstances.

In TypeScript we need to write top-level functions that serve as runtime and compile-time type-guards. For example, to check if the window.error event-listener is really receiving an ErrorEvent instead of an Event I'd write this:

function isErrorEvent( e: unknown ): e is ErrorEvent {
    
    // TODO
}

function onWindowError( e: unknown ): void {
    
    if( isErrorEvent( e ) ) {
        // do stuff with `e.error`, etc.
    }
}

window.addEventListener( 'error', onWindowError );

My question is about how I'm meant to idiomatically implement isErrorEvent the way that the TypeScript language designers intend-for. I haven't been able to find any authoritative documentation on the subject.

Specifically, I don't know how I'm supposed to use runtime typeof checks to implement isErrorEvent without using either a type-assertion to any or to the destination type ErrorEvent. As far as I know, either of those techniques is required because TypeScript will not let you use typeof x.y when y is not part of x's static type - which strikes me as odd because TypeScript does let you use typeof x when x is a scalar of any type, not just its static type.

Below, using as any works, but I don't like the lack of safety from the asAny.colno property dereferences:

function isErrorEvent( e: unknown ): e is ErrorEvent {
    if( !e ) return;
    const asAny = e as any;
    return (
        typeof asAny.colno  === 'number' &&
        typeof asAny.error  === 'object' &&
        typeof asAny.lineno === 'number'
    );
}

The alternative is to use as ErrorEvent, but I feel that's just as unsafe because TypeScript then allows dereferencing of members of e without a prior typeof check!

function isErrorEvent( e: unknown ): e is ErrorEvent {
    if( !e ) return;
    const assumed = e as ErrorEvent;
    return (
        typeof assumed.colno  === 'number' &&
        typeof assumed.error  === 'object' &&
        typeof assumed.lineno === 'number' &&
        
        // For example, TypeScript will not complain about the line below, even though I haven't proved that `e.message` actually exists, just because `ErrorEvent.message` is defined in `lib.dom.d.ts`:
        assumed.message.length > 0
    );
}

I suppose what I'm asking is how can I make something like this (see below) work, where TypeScript requires every member be checked with typeof before allowing any dereference, and allowing e to retain its static-type as unknown:

function isErrorEvent( e: unknown ): e is ErrorEvent {
    if( !e ) return;
    return (
        typeof e.colno  === 'number' &&
        typeof e.error  === 'object' &&
        typeof e.lineno === 'number' &&
        
        typeof e.message === 'string' &&
        e.message.length > 0
    );
}

...but TypeScript does let us do this (see below) which is arguably the same thing, just syntactically far more verbose:

function isErrorEvent( e: unknown ): e is ErrorEvent {
    if( !e ) return;

    const assume = e as ErrorEvent;
    
    if(
        typeof e.colno  === 'number' &&
        typeof e.error  === 'object' &&
        typeof e.lineno === 'number' &&
    )
    {
        const message = assume.message as any;
        return typeof message === 'string' && message.length > 0;
    }
}

Upvotes: 2

Views: 1366

Answers (2)

VLAZ
VLAZ

Reputation: 28970

Type guards are one of the few places I find that any is quite acceptable. You have basically two types of type guards based on their parameters

  • they take a number of things, usually a union (for example, A | B | C) and narrow down the union (for example, to B).
  • they take a thing that is not well known what it is and give it shape.

In the former case, you can easily work within the bounds of the type system to narrow down stuff.

In the latter case, you have a varying amount of "shapelessness" to work with but in extreme cases (like your unknown) you have no type support and this leads to something that will look a bit ugly. See here:

type HasProp<T extends object, K extends string> = T & {[P in K]: unknown};

/*
 * type guard to ensure that an arbitrary object has a given property
 */
function hasProp<T extends object, K extends string>(obj: T, prop: K): obj is HasProp<T, K> {
    return prop in obj;
}

function isErrorEvent( e: unknown ): e is ErrorEvent {
    if( !e ) return false;
    
    if (
        typeof e === "object" && //type guard determines `e` is `object | null`
        e !== null               //type guard to narrow down `e` to only `object`
    ) {
        if (
                hasProp(e, "colno") &&   //type guard to add `colno` to the properties known to be in `e`
                hasProp(e, "error") &&   //type guard to add `error` to the properties known to be in `e`
                hasProp(e, "lineno") &&  //type guard to add `lineno` to the properties known to be in `e`
                hasProp(e, "message")    //type guard to add `message` to the properties known to be in `e`
            ){
                return (
                    typeof e.colno  === 'number' &&
                    typeof e.error  === 'object' &&
                    typeof e.lineno === 'number' &&
                    
                    typeof e.message === 'string' &&
                    e.message.length > 0
                );
        }
    }
    return false;
}

Playground Link

I want to be clear - all the operations this code does are correct. You cannot check if e has some arbitrary properties if it is not an object. And checking if an arbitrary property value is a given type is a bit useless without checking if the property exists.

With that said, it is overly verbose and also a bit obtuse.

  • The e !== null is useless as it's already handled by !e in the beginning.
  • Checking if a property exists in order to check if its value is a number is directly equivalent to checking if the value is a number. There is usually no difference - if the property doesn't exist of its value is a different type it is all the same in the end.

So, instead of that, I am personally happy to type e as any. If you want a compromise between some type safety and less obtuse code to write, then you can use type it as Record

function isObj(obj: unknown): obj is Record<PropertyKey, unknown> {
    return typeof obj === "object" && obj !== null;
}

function isErrorEvent( e: unknown ): e is ErrorEvent {
    if ( isObj(e) ) {
        return (
            typeof e.colno  === 'number' &&
            typeof e.error  === 'object' &&
            typeof e.lineno === 'number' &&
            
            typeof e.message === 'string' &&
            e.message.length > 0
        );
    }

    return false;
}

Playground Link

To me the above code is much simpler to read and understand. It is not as rigorously checked by the compiler but it is also completely correct. It's also acts exactly the same when using any hence why I don't oppose it. As long as you do the appropriate check that you have an object, it matters little whether it's Record or any. You're not getting any type support from the compiler either way. The latter is slightly more correct in terms of types but whether that makes a difference is up to you.


Note 1: You can also use a type assertion e as Record<PropertyKey, unknown>. Doesn't matter much but the additional isObj type guard seems more likely to be reused.


Note 2: Just for the record, the hasProp can be changed to apply to multiple properties. It doesn't solve the core of the issues I have with using it in a type guard but it might still be useful elsewhere:

/*
 * type guard to ensure that an arbitrary object has a given properties
 */
function hasProps<T extends object, K extends PropertyKey>(obj: T, ...props: K[]): obj is HasProp<T, K> {
    return props.every(prop => prop in obj);
}

/* ... */
if (hasProps(e, "colno", "error", "lineno", "message")) { //type guard to add `colno`, `error`, `lineno`, `message` to the properties known to be in `e`
/* ... */

Playground Link

Upvotes: 3

ankhzet
ankhzet

Reputation: 2570

// For example, TypeScript will not complain about the line below, even though I haven't proved that e.message actually exists, just because ErrorEvent.message is defined in lib.dom.d.ts

In cases like this, you should not try to prove, that it's exactly an instance of some concrete class, but only define some specific, narrow subset of it's features, that you are actually interested in using, and then type-check for that specific shape.

For example, you aren't really interested, if e is an actual ErrorEvent instance, you only care if it conforms to some narrow contract:

interface IErrorEvent {
    message: string;
    lineno: number;
    colno: number;
}

Now you just need to type-guard for that exact contract. The best solution for it i came up with is to use an approach, similar to @VLAZ's, just add a generic for inference to isObj helper, to get some protection from typos while writing narrowing type-checks themselves:

const isLike = <T extends object>(o: unknown): o is Record<keyof T, unknown> => (
    (o ?? false) && typeof o === 'object'
);

const isErrorEvent = (e: unknown): e is IErrorEvent => (
    isLike<IErrorEvent>(e)
        // e at this point is a { message: unknown; lineno: unknown; colno: unknown }, not IErrorEvent or ErrorEvent
        && (typeof e.message === 'string') // ok
        && (typeof e.lineno === 'number') // ok
        && (typeof e.collno === 'number') // TS2551: Property 'collno' does not exist on type 'Record '. Did you mean 'colno'?
);

Upvotes: 0

Related Questions