Typescript `in` operator type guard narrowing only as literal

764 Views Asked by At

Does type narrowing with the in operator only work for literals or am I missing something? Help me understand why this is happening please.

interface A {
    a: string;
}

interface B {
    b: number;
}

// This narrows the type correctly to A
function test(arg: A | B): string {
    if ('a' in arg) {
        return arg.a;
    }
    return arg.b.toFixed(2);
}

// This doesn't
function test2(arg: A | B): string {
    let a = 'a';
    if (a in arg) {
        return arg.a;
    }
    return arg.b.toFixed(2);
}

2

There are 2 best solutions below

4
On BEST ANSWER

Regarding to docs

For a n in x expression, where n is a string literal or string literal type and x is a union type, the “true” branch narrows to types which have an optional or required property n, and the “false” branch narrows to types which have an optional or missing property n.

So, I'd willing to bet it works only with literals

Workarounds

interface A {
    a: string;
}

interface B {
    b: number;
}

// This narrows the type correctly to A
function test(arg: A | B): string {
    if ('a' in arg) {
        return arg.a;
    }
    return arg.b.toFixed(2);
}

const isIn = <T, Prop extends string>(obj: T, prop: Prop): obj is T & Record<Prop, unknown> => prop in obj
const isIn2 = <T, Prop extends string>(obj: T, prop: Prop): obj is T & Record<Prop, unknown> =>
    Object.prototype.hasOwnProperty.call(obj, prop)

// This doesn't
function test2(arg: A | B): string {
    const a: 'a' = 'a' as 'a';
    if (isIn(arg, a) /** OR isIn2(arg, a) */) {
        return arg.a; // A        
    }


    return arg.b.toFixed(2);
}

0
On

Edit:

This is my improved solution

// This is an object as const so DataUuid is a string union
type DataUuid = keyof typeof peripheralsValueUuidNameMap

// And I used it here as well because they are all the same strings
type ServCharMap = {
  [key in DataUuid]: ServChar
}

if (characteristic.uuid in servCharMap) {
  // So now we only have the external data as a forced type.
  // Still surprised this is not inferred.
  const dataUuid = characteristic.uuid as DataUuid

  const charData = servCharMap[dataUuid]

  if (charData.bytes === 2) {
    // We can now lose this `in` check
    const name = peripheralsValueUuidNameMap[dataUuid]

  ...

  }
}

See below for my reasoning

Yes I agree this is strange. I have the following code:

let name = ''

if (characteristic.uuid in peripheralsValueUuidNameMap) {
  const uuid =
    characteristic.uuid as keyof typeof peripheralsValueUuidNameMap

  name = peripheralsValueUuidNameMap[uuid]
}

I woud assume ts infers the as keyof typeof peripheralsValueUuidNameMap but it only stops complaining if I force it in this way.

I KNOW characteristic.uuid is a key of peripheralsValueUuidNameMap because the in check succeeded.

peripheralsValueUuidNameMap is a const type so everything about it is known. The problem is, all keys are const strings so can not be indexed by string type. Strangely, even if the in check has completed. I would assume the type of characteristic.uuid to become a union of all key strings of peripheralsValueUuidNameMap and that is exactly what as keyof typeof does.

In this case it does work:

if (characteristic.uuid in servCharMap) {
  const charData = servCharMap[characteristic.uuid]
}

Because servCharMap has type

type ServCharMap = {
  [key: string]: ServChar
}

But here we don't even need the in check, const charData = servCharMap[characteristic.uuid] this code alone does not give a type error because we are indexing servCharMap with a string type. This means for all TS cares charData can evaluate to undefined.

characteristic can not be typed as a const type because it comes from an external data source.

So in conclusion, both situations have flaws and hide footguns.