Jerryc
Jerryc

Reputation: 310

typescript how to type the subset of a string literal array

I have function, createFields, taking a generic Object type, in this case, User, with a set of keys. I want the type the subset of these keys, to be inferred with a string literal array, like selectable property in Fields. Can I do it in typescript? If yes, how can i achieve it? Thank you.

Here is the code
interface User {
  id: number;
  username: string;
  password: string;
  age: number;
}

interface Fields<T extends object = {}> {
  selectable: Array<keyof T>;
  sortable: Array<keyof T>;
}

function createFields<T extends object = {}>({ selectable, sortable }: Fields<T>) {
  return {
    // How can I get type of selectable to be ("id" | "username" | "age")[]
    select(fields: typeof selectable): string {
      return fields.join(',')
    }
  }
}
const UserFields: Fields<User> = {
  selectable: ['id', 'username', 'age'],
  sortable: ['username', 'age']
}

const fields = createFields(UserFields)
// actual
// => fields.select = (fields: ("id" | "username" | "password" | "age")[]): string;

// expected 
// => => fields.select = (fields: ("id" | "username" | "age")[]): string;

Upvotes: 0

Views: 1886

Answers (1)

jcalz
jcalz

Reputation: 328453

The problem is that Fields<T> isn't sufficiently generic. Its selectable and sortable properties are Array<keyof T>, which is too wide to remember which subset of keyof T is used. The way I'd deal with this is to make createFields() accept a generic type F that is constrained to Fields<T>.

One issue you face doing this is that the compiler can't do partial type argument inference. You either need to specify both T and F, or let the compiler infer both. And there really isn't anything for the compiler to infer for T. Assuming you want to specify T the way I'd do this is to use currying:

const createFieldsFor =
    <T extends object>() => // restrict to particular object type if we want
        <F extends Fields<T>>({ selectable, sortable }: F) => ({
            select(fields: readonly F["selectable"][number][]): string {
                return fields.join(',')
            }
        })

If you don't care about the particular T, then you can just leave it unspecified entirely:

const createFields = <F extends Fields<any>>({ selectable, sortable }: F) => ({
    select(fields: readonly F["selectable"][number][]): string {
        return fields.join(',')
    }
})

Notice that the argument to fields is readonly F["selectable"][number][], meaning: look up the "selectable" property of F, and look up its number index type (F["selectable"][number])... and we are accepting any array or readonly array of that type. The reason I don't just use F["selectable"] is because if that type is a tuple of fixed order and length, we don't want fields to require the same order/length.


For either the curried or non-curried case you need to make UserFields an object type that doesn't widen to Fields<T> and doesn't widen selectable and sortable to string[]. Here's one way to do it:

const UserFields = {
    selectable: ['id', 'username', 'age'],
    sortable: ['username', 'age']
} as const; // const assertion doesn't forget about sting literals

I used a const assertion to keep UserFields narrow. This as the side effect of making the arrays readonly which Fields<T> doesn't accept, so we can change Fields<T> to allow both regular and read-only arrays:

interface Fields<T extends object> {
    // readonly arrays are more general than arrays, not more specific
    selectable: ReadonlyArray<keyof T>;
    sortable: ReadonlyArray<keyof T>;
}

Or you can make UserFields some other way instead. The point is you can't annotate it as Fields<User> or it will forget everything you care about.


The curried solution is used like this:

const createUserFields = createFieldsFor<User>(); 

const fields = createUserFields(UserFields); // okay

const badFields =
    createUserFields({ selectable: ["password"], sortable: ["id", "oops"] }); // error!
    // "oops" is not assignable to keyof User ------------------> ~~~~~~

Notice how there's an error in badFields because createUserFields() will complain about non-keyof User properties.

Let's make sure the fields that comes out works as expected:

fields.select(["age", "username"]); // okay
fields.select(["age", "password", "username"]); // error!
// -----------------> ~~~~~~~~~~
// Type '"password"' is not assignable to type '"id" | "username" | "age"'

That's what you wanted, right?


The non-curried don't-care-about-User solution is similar, but it won't catch "oops":

const alsoFields = createFields(UserFields);
const alsoBadFields =
    createFields({ selectable: ["password"], sortable: ["id", "oops"] }); // no error

I guess which one (if any) you want to use depends on your use case. The main point here is that you need createFields() to be generic in the type of the keys in the selectable and sortable properties. Exactly how you do that is up to you. Hope that helps; good luck!

Playground link to code

Upvotes: 1

Related Questions