3

My application receives "messages". I first validate an unknown input to ensure it follows the expected message format:

const isMessage = x => 
  typeof x === 'object' && 
  x !== null && 
  typeof x['data'] === 'string';

I wish to type this in TypeScript. Here's what I have:

type Message = { data: string };

const isMessage = (x: unknown): x is Message => 
  typeof x === 'object' && 
  x !== null && 
  typeof x['data'] === 'string';

However, this fails to type-check, because:

Element implicitly has an 'any' type because expression of type '"data"' can't be used to index type '{}'.
  Property 'data' does not exist on type '{}'.

After the type guard typeof x === 'object' && x !== null, TypeScript gives the typing x : object. This seems to be the same as x : {}. But this type does not allow me to check any properties on the object.

Instead of x: object, I think I want a "dictionary" type like x: { [key: string | number | symbol]: unknown }. But this is not the typing that TypeScript gives me from the type guard typeof x === 'object'.

I can use as to cast x to a dictionary type:

const isMessage = (x: unknown): x is Message => 
  typeof x === 'object' && 
  x !== null && 
  typeof (x as { [key: string | number | symbol]: unknown })['data'] === 'string';

This type-checks, but it's really long and unwieldy, and I'm not sure the as typecast is really type-safe.

I read about the in operator narrowing, and based on this, I expected that adding 'data' in x would work:

const isMessage = (x: unknown): x is Message => 
  typeof x === 'object' && 
  x !== null && 
  'data' in x &&
  typeof x['data'] === 'string';

However, this makes no difference; TypeScript still complains that I can't index into x, even at a point where 'data' in x. Why does this in operator not allow me to index into x?

jameshfisher
  • 34,029
  • 31
  • 121
  • 167

2 Answers2

2

You should be able to do it like this:

type Message = { data: string };

const isMessage = (x: unknown): x is Message => 
  typeof x === 'object' && 
  x !== null && 
  typeof (x as Message).data === 'string';

This technique is shown in TypeScript's docs: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates


Since typeof is also a runtime check, the as assertion does not remove any type safetyness.

You can think of it this way: Before the last line, we have already checked that x is an object and is not null. So, x.data cannot fail at runtime, even if x would be {} or {bar: 'bar'} or {data: null}. We just need to use the assertion to make the compiler allow us to do the runtime typeof check.

ruohola
  • 21,987
  • 6
  • 62
  • 97
  • Thanks, this seems to type-check. Although it feels like cheating - I'm casting `x` to the `Message` type _before_ I really know that it fits that type! – jameshfisher Oct 21 '21 at 09:56
  • (In other words, TypeScript would be justified to say, "this `typeof` check is useless, because we already know `typeof x.data === 'string'`, because `x : Message`") – jameshfisher Oct 21 '21 at 09:58
  • @jameshfisher Added an explanation to my answer. – ruohola Oct 21 '21 at 10:06
  • Thanks - I can certainly see that the _runtime_ behavior is correct. I still think the `as` operator removes type safety though. [The docs](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-assertions) say "TypeScript only allows type assertions which convert to a **more specific** or less specific version of a type" (emphasis mine). That is, `as` is inherently unsafe. For example, `x as Message` is certainly unsafe, because at that point, we only have `x : object`. Ideally, I'm looking for a method that entirely avoids using `as`. – jameshfisher Oct 21 '21 at 10:12
  • (Or another way of saying it - using `x as any` would be just as type-safe as `x as Message`) – jameshfisher Oct 21 '21 at 10:13
  • 1
    @jameshfisher, this is a run-time check, so you can't rely on TypeScript "type safety", and yes `x as any` is just as good -- it lets the code compile and that's all. In the end it all boils down to `typeof`, which is a JavaScript run-time operator. – tromgy Oct 21 '21 at 10:24
  • Personally, I treat `any` in the same way as `unsafe` in `Rust`. In some cases it is safe to use `any` but the more `any` you have - the more unsafe your code is – captain-yossarian from Ukraine Oct 21 '21 at 10:28
  • @jameshfisher This is the canonical way to do it, as per the docs. – ruohola Oct 21 '21 at 18:56
2

You can use generic hasProperty helper for property check:

type Message = { data: string };

const hasProperty = <Obj, Prop extends string>(obj: Obj, prop: Prop)
    : obj is Obj & Record<Prop, unknown> =>
    Object.prototype.hasOwnProperty.call(obj, prop);

const isMessage = (x: unknown): x is Message =>
    typeof x === 'object' &&
    x !== null &&
    hasProperty(x, 'data') &&
    typeof x['data'] === 'string'

Playground

Please see my answer here for more context about in operator and this issue/43284