The issue highlighted here in TypeScript is currently unresolved and can be referred to at microsoft/TypeScript#17687. While there is an opportunity to show support for its implementation by giving a thumbs up on the aforementioned issue, it appears that active development on this feature may not be progressing. (Previously, some effort was made to work on features that could have facilitated this, but it seems like they are stagnant now).
Presently, there are only workarounds available:
The intersection methods proposed in other responses might meet the requirements for your scenario, but they do not ensure complete type safety or consistency. Although
type FilterItemIntersection = { [key: string]: string | undefined; } & { stringArray?: string[]; }
does not trigger an immediate compiler error, the resulting type seems to mandate the
stringArray
property to be both
string | undefined
and string[] | undefined
, a type that is equivalent to
undefined
, which is not the intended behavior. Fortunately, you can manipulate the
stringArray
property from an existing value of type
FilterItemIntersection
and the compiler will interpret it as
string[] | undefined
instead of
undefined
:
function manipulateExistingValue(val: FilterItemIntersection) {
if (val.foo) {
console.log(val.foo.toUpperCase()); // permissible
}
val.foo = ""; // permissible
if (val.stringArray) {
console.log(val.stringArray.map(x => x.toUpperCase()).join(",")); // permissible
}
val.stringArray = [""] // permissible
}
However, assigning a value directly to that type will likely result in an error:
manipulateExistingValue({ stringArray: ["oops"] }); // error!
// -------------------> ~~~~~~~~~~~~~~~~~~~~~~~~~
// Property 'stringArray' is incompatible with index signature.
This will necessitate additional steps to obtain a value of that type:
const hoop1: FilterItemIntersection =
{ stringArray: ["okay"] } as FilterItemIntersection; // assert
const hoop2: FilterItemIntersection = {};
hoop2.stringArray = ["okay"]; // multiple statements
Another workaround involves representing the type as generic rather than concrete. Express the property keys as a union type K extends PropertyKey
, like so:
type FilterItemGeneric<K extends PropertyKey> =
{ [P in K]?: K extends "stringArray" ? string[] : string };
To obtain a value of this type, you would need to manually annotate and specify K
, or utilize a helper function to infer it for you as follows:
const filterItemGeneric =
asFilterItemGeneric({ stringArray: ["okay"], otherVal: "" }); // permissible
asFilterItemGeneric({ stringArray: ["okay"], otherVal: ["oops"] }); // error!
// string[] is not string ----------------> ~~~~~~~~
asFilterItemGeneric({ stringArray: "oops", otherVal: "okay" }); // error!
// string≠string[] -> ~~~~~~~~~~~
Although this approach achieves the desired outcome, it is more intricate than the intersection version when modifying a value of this type with an unspecified generic K
:
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 guaranteed to be present
}
It is feasible to combine these workarounds by utilizing the generic version for creating and checking values with known keys, while resorting to the intersection version for manipulating values with unknown keys:
const filterItem = asFilterItemGeneric({ stringArray: [""], otherVal: "" }); // permissible
function manipulate<K extends PropertyKey>(_val: FilterItemGeneric<K>) {
const val: FilterItemIntersection = _val; // successful
if (val.otherVal) {
console.log(val.otherVal.toUpperCase());
}
if (val.stringArray) {
console.log(val.stringArray.map(x => x.toUpperCase()).join(","));
}
}
Stepping back, the most TypeScript-friendly solution is to avoid using such a structure altogether. If feasible, consider switching to a pattern like this, where your index signature remains unaffected by incompatible values:
interface FilterItemTSFriendly {
stringArray?: string[],
otherItems?: { [k: string]: string | undefined }
}
const filterItemFriendly: FilterItemTSFriendly =
{ stringArray: [""], otherItems: { otherVal: "" } }; // permissible
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 approach requires no tricks, intersections, generics, or complications. Therefore, it is recommended if possible.
Hopefully, this information proves helpful. Best of luck!
Playground link