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