3
votes

I already came across multiple situations where it would have been nice to do the following (very abstracted):

export interface FilterItem {
  [key: string]: string | undefined;
  stringArray?: string[];
}

Only this will throw an error because string[] is not assignable to string, which makes sense.

I'm just wondering if it's possible and if so, how, to create an interface that has some properties defined like the stringArray which might not follow the key index signature. Therefore the key index signature just has the purpose to define types for every other property.

Is it possible?

2

2 Answers

3
votes

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

1
votes

One way of doing it is by extending an interface with the stringArray type

interface SArr {
  stringArray?: string[];
}

interface FilterItem extends SArr { 
    [key: string]: any
}

const x: FilterItem = {
    stringArray: 1 // error
    stringArray: ['hello'] // ok
}

You can also use type combined with Record to make it more readable

type SArr = { // can also be an interface
  stringArray?: string[];
}

type FilterItem = SArr & Record<string, any>

const x: FilterItem = {
    stringArray: 1 // error
    stringArray: ['hello'] // ok
}