Reputation: 310
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
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!
Upvotes: 1