in operator narrowing

Narrows a union by testing whether a property name exists on the object.

Signature
"propertyName" in objectValue
  • Added in TypeScript 2.7

Parameters

ParameterTypeDescription
$propertyNamestring | number | symbolA string literal or literal-typed key. A variable of type string does not narrow.
$objectValueobjectThe value being probed. Must be assignable to object; primitives are a compile error and a TypeError at runtime.

Return value

booleanA boolean. The true branch narrows to union members that have the property as required or optional; the false branch narrows to members where it is optional or absent.

Errors

TypeError at runtime when the right operand is not an object, including null. tsc rejects a right operand that is not assignable to object since 4.9.

Changelog

  • PHP 2.7Type guards inferred from the `in` operator: for `n in x` where n is a string literal and x is a union, the true branch keeps members with an optional or required property n, the false branch keeps members where n is optional or missing.
  • PHP 4.9Unlisted property narrowing: `in` now intersects the type with Record<key, unknown>, so `"name" in obj` lets you read obj.name even when obj is only known to be object. Validation also tightened so the operands must be assignable to string | number | symbol and object.

The in operator narrows structurally: instead of asking what a value is, you ask what it has. That makes it the natural guard for unions of plain object shapes, which instanceof cannot touch.

When to reach for it

  • A union of two object types with no shared tag field, where adding a discriminant would mean changing an API you do not own.
  • Probing an unknown payload field by field — since 4.9 this narrows object down to Record<"key", unknown>, so the property access itself compiles.
  • Feature detection on a value whose shape varies by version.

What trips people up

Optional properties land in both branches. Given type Human = { swim?(): void; fly?(): void }, "swim" in animal narrows to Fish | Human in the true branch and Bird | Human in the false one, because an optional property may or may not be present. You still have to check the value, not just the key.

in also walks the prototype chain, so inherited methods answer true. Use Object.hasOwn when you mean own properties only.

Finally the key must be a literal. const k = "swim"; if (k in animal) narrows only because k infers the literal type "swim" — annotate it : string and the narrowing disappears.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Handling a success-or-error response union that has `data` on one side and `error` on the other
  • Reading a field off an unknown JSON payload without casting
  • Feature-detecting an optional method on a shape you did not define

Frequently asked questions

Why does my type still appear in the else branch after an in check?

Because the property is optional on that type. `in` keeps any member where the property is optional in both branches, since an optional property may legitimately be missing at runtime.

Does the in operator see inherited properties?

Yes — it walks the prototype chain, so methods from a class prototype return true. Use Object.hasOwn(obj, key) when you need own properties only.

Why doesn't `if (key in obj)` narrow when key is a variable?

Narrowing needs a literal key type. A `const key = "swim"` infers the literal type and works; a `let key: string` does not, because any string could match.

More examples

A result union without a discriminant field

Prints [ '3 rows', 'failed (504): timeout' ]. This is the shape you get from many third-party SDKs. It works, but a literal `kind` field would narrow more reliably — see discriminated unions — because `in` breaks down the moment either side gains an optional property with the same name.

Example · typescript
type Ok = { data: string[] };
type Err = { error: string; code: number };
type Result = Ok | Err;

function render(r: Result): string {
  if ("error" in r) return `failed (${r.code}): ${r.error}`;
  return `${r.data.length} rows`;
}

const results: Result[] = [
  { data: ["a", "b", "c"] },
  { error: "timeout", code: 504 },
];

console.log(results.map(render));

Unlisted property narrowing on unknown (4.9+)

Prints typescript, undefined, undefined. Before TypeScript 4.9 both `"name" in pkg` accesses errored with "Property 'name' does not exist on type 'object'" and you had to cast. The `pkg &&` guard is still required, because typeof null is "object" and `in` throws a TypeError on null.

Example · typescript
function getPackageName(pkg: unknown): string | undefined {
  if (pkg && typeof pkg === "object") {
    // Since 4.9, `in` intersects with Record<"name", unknown>,
    // so pkg.name is readable even though `object` has no such property.
    if ("name" in pkg && typeof pkg.name === "string") {
      return pkg.name;
    }
  }
  return undefined;
}

console.log(getPackageName({ name: "typescript", version: "5.9" }));
console.log(getPackageName({ version: "5.9" }));
console.log(getPackageName(null));

Discussion

  • Be the first to comment on this lesson.