Object types in TypeScript are open in the sense that if you have a value of type (say) {a: string, b: number}
, while you can be sure that there will be a string
property at key "a"
and a number
property at key "b"
, you cannot be sure that there will not be properties at keys other than "a"
and "b
". This openness allows for things like interface
and class
extensions, where sub-interfaces and sub-classes can have more properties than their parent interfaces and classes without violating the contract of the parent interface or class type. TypeScript does not have "exact types" (as requested in microsoft/TypeScript#12936) where you can say that Exact<{a: string, b: number}>
is like {a: string, b: number}
but is known to have no other properties but "a"
and "b"
. So unless you add a string
index signature to your types, the compiler will balk at the suggestion that the keys of an object will be limited to the ones it knows about. See Why doesn't Object.keys return a keyof type in TypeScript? for a possibly canonical answer here. The safe way to do this is to iterating over a hardcoded array of known keys, like ["numbers", "other_numbers", /* etc */]
.
But if you already had JavaScript code that was iterating over object properties with something like Object.keys()
or Object.entries()
, and it was working for you, then you were already running this risk in JavaScript to no apparent harm. If you'd like to continue doing this instead of rewriting your code, then you could always use type assertions to tell the compiler that while you appreciate its concern for the safety of your code, you are sure that it is safe enough for your purposes. For example:
function merge<T>(primary: { [K in keyof T]: Array<T[K]> }, child: T) {
// the following type assertion is not always safe,
// since primary may in fact have keys not known to the compiler
// but we will assume that it doesn't
const childKeys = Object.keys(child) as Array<keyof T>;
childKeys.forEach(<K extends keyof T>(k: K) => primary[k].push(child[k]))
}
This implementation of merge()
is a generic function that works the way you've described. The type assertion at childKeys
is where you tell the compiler that you know that Object.keys(child)
is an array of string
, but you will treat it as an array of keyof T
and face whatever consequences may come of being mistaken about that.
You could also do it this way instead:
function merge2<T>(primary: { [K in keyof T]: Array<T[K]> }, child: T) {
for (const k in child) {
primary[k].push(child[k]);
}
}
Here the compiler actually makes the same (sometimes unjustified) assumption that you might make that for (const k in child)
will iterate over only keys found in type T
. And so you don't need any type assertions at all.
The important piece of this answer is that type assertions are okay. The compiler cannot always know what you know about the code. Sometimes the compiler is wrong, and a type assertion is useful to work around that. Sometimes it is technically correct but the issue it raises is unlikely to come up in your code and instead of rewriting your code, a type assertion can be used to just quiet the complaint. In either case, there will be times when you want the compiler to focus on other parts of your code instead of warning about something that is actually fine. At such times it makes sense for you to sit down and think hard about what you are doing to make sure that an assertion is warranted. If so, go for it!
Playground link to code