This is an open issue in TypeScript; see microsoft/TypeScript#17687. If you want to see this implemented you might want to go to that issue and give it a š, but I don't have any sense that it's being actively worked on. (A little while ago there was some working being done on some features that would have enabled this, but it doesn't look like those are moving forward).
Right now there are just workarounds:
The intersection techniques suggested in the other answers might be sufficient for your use case but they are not fully type safe or consistent. While type FilterItemIntersection = { [key: string]: string | undefined; } & { stringArray?: string[]; }
does not result in an immediate compiler error, the resulting type seems to require that the stringArray
property be both string | undefined
and string[] | undefined
, a type equivalent to undefined
, and not what you want. Luckily enough you can read/write the stringArray
property from an existing value of type FilterItemIntersection
and the compiler will treat it as string[] | undefined
instead of undefined
:
function manipulateExistingValue(val: FilterItemIntersection) {
if (val.foo) {
console.log(val.foo.toUpperCase()); // okay
}
val.foo = ""; // okay
if (val.stringArray) {
console.log(val.stringArray.map(x => x.toUpperCase()).join(",")); // okay
}
val.stringArray = [""] // okay
}
But straightforward assignment of a value to that type will probably give you an error:
manipulateExistingValue({ stringArray: ["oops"] }); // error!
// -------------------> ~~~~~~~~~~~~~~~~~~~~~~~~~
// Property 'stringArray' is incompatible with index signature.
This will force you to jump through hoops to get a value of that type:
const hoop1: FilterItemIntersection =
{ stringArray: ["okay"] } as FilterItemIntersection; // assert
const hoop2: FilterItemIntersection = {};
hoop2.stringArray = ["okay"]; // multiple statements
Another workaround is to represent your type as generic instead of concrete. Express the property keys as some union type K extends PropertyKey
, as in:
type FilterItemGeneric<K extends PropertyKey> =
{ [P in K]?: K extends "stringArray" ? string[] : string };
Getting a value of this type involves either manually annotating and specifying K
, or using a helper function to infer it for you like this:
const filterItemGeneric =
asFilterItemGeneric({ stringArray: ["okay"], otherVal: "" }); // okay
asFilterItemGeneric({ stringArray: ["okay"], otherVal: ["oops"] }); // error!
// string[] is not string ----------------> ~~~~~~~~
asFilterItemGeneric({ stringArray: "oops", otherVal: "okay" }); // error!
// stringā string[] -> ~~~~~~~~~~~
This is exactly what you want, but unfortunately it's more difficult than the intersection version to manipulate a value of this type if K
is an unspecified generic:
function manipulateGeneric<K extends PropertyKey>(val: FilterItemGeneric<K>) {
val.foo; // error! no index signature
if ("foo" in val) {
val.foo // error! can't narrow generic
}
val.stringArray; // error! not necessarily present
}
It's possible to combine these workarounds in a way where you use the generic version when creating and checking values with known keys, but the intersection version when manipulating values with unknown keys:
const filterItem = asFilterItemGeneric({ stringArray: [""], otherVal: "" }); // okay
function manipulate<K extends PropertyKey>(_val: FilterItemGeneric<K>) {
const val: FilterItemIntersection = _val; // succeeds
if (val.otherVal) {
console.log(val.otherVal.toUpperCase());
}
if (val.stringArray) {
console.log(val.stringArray.map(x => x.toUpperCase()).join(","));
}
}
But backing up, the most TypeScript-friendly answer is not to use such a structure in the first place. If possible, switch to something like this, where your index signature can remain untainted by incompatible values:
interface FilterItemTSFriendly {
stringArray?: string[],
otherItems?: { [k: string]: string | undefined }
}
const filterItemFriendly: FilterItemTSFriendly =
{ stringArray: [""], otherItems: { otherVal: "" } }; // okay
function manipulateFriendly(val: FilterItemTSFriendly) {
if (val.stringArray) {
console.log(val.stringArray.map(x => x.toUpperCase()).join(","));
}
if (val.otherItems?.otherVal) {
console.log(val.otherItems.otherVal.toUpperCase());
}
}
This requires no tricks, intersections, generics, or tears. So that is my recommendation if at all possible.
Okay, hope that helps; good luck!
Playground link