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

2

2
  • Since TypeScript 3.0 introduced the unknown top type in mid-2018, use of the any type is discouraged.
  • TypeScript also had had long support for succint type-guards with the typeof operator, but typeof only works as a first-class type-guard for scalar values (i.e. single variables).
    • The major caveat being that it cannot be used with object properties or array elements without first using as any or as T.
      • Using as any has immediately obvious problems.
      • But using as T also introduces its own problems. This isn't that big a problem inside a type-guard function as the scope of the variable-with-assumed-type is limited to the type-guard, but if used inside a normal function it can introduce bugs.

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;
    }
}
Bergeron answered 16/2, 2021 at 6:1 Comment(3)
Eh, this is one of the places where any isn't that bad. A type guard often receives something and it has to determine whether it fulfills the criteria to be something else. You often cannot do that statically, otherwise you don't really need the type guard. There are some instances where you can do this like a union of A | B and want to type guard for A but for arbitrary values it's inherently unsafe. You can type assert e as Record<string, any> if it makes you feel safer but I'm not sure there is a convenient way to be completely type safe here.Unspeakable
@Unspeakable Right, but I’m surprised that typeof x.y (where x is unknown) isn’t allowed by TypeScript - so I was wondering if I was overlooking something simple. I suppose I could check for typeof object to allow the arbitrary string property indexer to be used though...Bergeron
You can be more typesafe and satisfy the compiler but IMO it's useless to do that in a typeguard. It leads to comparably uglier code that's only there to satisfy the compiler. See this example. You need a typeguard to tell the compiler that the property you're checking can be checked and then check the property. I find it weird and roundabout.Unspeakable
U
3

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

Unspeakable answered 16/2, 2021 at 10:53 Comment(2)
Typescript has PropertyKey which is defined the same way as your ObjectKey, by the way.Supporting
@Supporting thanks a lot for that. I never managed to find this type, even though I looked for it. That's quite useful.Unspeakable
L
0

// 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'?
);
Loritalorn answered 16/4, 2023 at 22:24 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.