typescript

How to do a Type Guard check on an Indexed Property


I have a nestedObj type which uses indexed signature as so:

type nestedObj = {[key: string]: nestedObj} | {[key: string]: number}

How would I create a type guard for this nestedObj type?

  const isNestedObj = (obj: any): obj is nestedObj => {
      if (obj === null || obj === undefined)
         return false;

      ??????
      
      return true;
  }

========= EDIT ===========

Sorry here is more information. The nested Obj is of the following

const testObj1 = { a: { b: { c: 42 } } };
const testObj2 = { ab: { bc: { cd: { d: 2 } } } };
const testObj3 = { xy: 3};

The object should only have exactly ONE key.
We do indeed also need to check if obj is a non-null object.

I had another go at it and came up with something like this

const isNestedObj = (obj: any): obj is nestedObj => {
    // terminating conditions
    if (typeof obj !== 'object' || obj === null || obj === undefined)
        return false
    
    // if number of keys is not exactly 1
    const objKeys = Object.keys(obj);
    if (objKeys.length !== 1)
        return false
    
    // if the next nested object is a number, return true
    if (typeof obj[objKeys[0]] === 'number')
        return true

    else return isNestedObj(obj[objKeys[0]]);
}

Seems to work for me, but not sure if this is the optimal solution


Solution

  • The type

    type NestedObj = { [key: string]: NestedObj } | { [key: string]: number }
    

    is a union of two object types, each with a string index signature. It means that a NestedObj must be an object whose properties all have to be of type number or all have to be of type NestedObj itself. The number of properties is not restricted. That gives rise to the following behavior:

    let n: NestedObj;
    
    n = {}; // okay
    n = { a: 0, b: 1, c: 2 }; // okay
    n = { a: {}, b: {}, c: {} }; // okay
    n = { a: { d: 0 }, b: { e: 1 }, c: { f: {} } }; // okay
    
    n = { a: 0, b: 1, c: "abc" }; // error, string is not allowed
    n = { a: 0, b: 1, c: {} }; // error, mix of numbers and objects
    n = { a: { d: 0 }, b: { e: 1 }, c: { f: { g: "abc" } } }; // error, nested string
    

    Note that the stated requirement that "the object should only have exactly ONE key" is not enforced by this type.

    So either we have to take your type at face value and write a type guard function for it, or we have to write a function that verifies your requirement but not treat it as a type guard function for that type. I'm going to do the former.

    The requirement that an object contain exactly one key isn't easily represented in TypeScript. Unless there's an overwhelmingly important use case for it, it would be better to relax the requirement or change the data structure to something enforceable, like [string, ...string[], number], and write ["a", "b", "c", 0] instead of {a:{b:{c:0}}}.


    Again, I'm going to write a custom type guard function that detects if an input is a valid NestedObj without caring about number of keys. Here's one approach:

    const isNestedObj = (obj: any): obj is NestedObj => {
      if (!obj) return false;
      if (typeof obj !== "object") return false;
      const values = Object.values(obj);
      if (values.every(v => typeof v === "number")) return true;
      return values.every(isNestedObj);
    }
    

    Here we make sure that obj is a nonnull object first. Then we use Object.values() to get all the property values of the object. If every property is a number, then the test passes. Otherwise, we recurse to see if every property is a NestedObj.

    This gives consistent behavior with the above tests:

    console.log(isNestedObj({})) // true
    console.log(isNestedObj({ a: 0, b: 1, c: 2 })) // true
    console.log(isNestedObj({ a: {}, b: {}, c: {} })) // true
    console.log(isNestedObj({ a: { d: 0 }, b: { e: 1 }, c: { f: {} } })) // true
    
    console.log(isNestedObj({ a: 0, b: 1, c: "abc" })) // false
    console.log(isNestedObj({ a: 0, b: 1, c: {} })) // false
    console.log(isNestedObj({ a: { d: 0 }, b: { e: 1 }, c: { f: { g: "abc" } } })); // false
    

    Note that the above function will fail to run if the passed-in object is actually circular, like:

    const o: NestedObj = { a: {} };
    o.a = o;
    console.log(isNestedObj(o)) // 💥 TOO MUCH RECURSION;
    

    If you need to account for this, you'd have to make isNestedObj() more complex to keep track of the objects it's already seen, so it can exit early if it enters a loop. I won't do that here.


    Note that if you try to enforce the "exactly one key" rule in a type guard function while keeping the type the same, you can get weird behavior:

    const isNestedObj = (obj: any): obj is NestedObj => {
      if (!obj) return false;
      if (typeof obj !== "object") return false;
      const values = Object.values(obj);
      if (values.length !== 1) return false; // exactly one key
      if (values.every(v => typeof v === "number")) return true;
      return values.every(isNestedObj);
    }
    
    const o = Math.random() < 0.99 ? { a: 1, b: 2 } : "abc";
    if (!isNestedObj(o)) {
      o // "abc"
      console.log(o.toUpperCase()) // 99% chance of a runtime error
    }
    

    The value {a: 1, b: 2} is a perfectly valid NestedObj according to the type. But isNestedObject({a: 1, b: 2}) now returns false. If isNestedObject is a type guard function, then TypeScript understands that to mean that !isNestedObject(o) implies that o is not a NestedObj. And so in the above example, TypeScript will incorrectly conclude that when o must be "abc".

    There are ways you can try to work around this, by making the type guard function "one sided" as described in microsoft/TypeScript#15048, or you could try to fight with the type system to come up with a type that only accepts objects with one property, as discussed at typescript restrict count of object's properties. But that makes things more complicated and I'll consider it out of scope here.

    Playground link to code