Reputation: 155055
unknown
top type in mid-2018, use of the any
type is discouraged.typeof
operator, but typeof
only works as a first-class type-guard for scalar values (i.e. single variables).
as any
or as T
.
as any
has immediately obvious problems.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;
}
}
Upvotes: 2
Views: 1366
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
A | B | C
) and narrow down the union (for example, to B
).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;
}
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.
e !== null
is useless as it's already handled by !e
in the beginning.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;
}
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`
/* ... */
Upvotes: 3
Reputation: 2570
// For example, TypeScript will not complain about the line below, even though I haven't proved that
e.message
actually exists, just becauseErrorEvent.message
is defined inlib.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