Reputation: 463
I would like to have a column definition map of all possible column names that are derived from different model types that are pre-defined. How can I get proper typings in the following?:
export type User = {
id: number;
admin: boolean;
email: string;
};
export type Book = {
id: number;
title: string;
author: string;
userId: number;
};
export type UserUID = {
id: string;
admin: boolean;
email: string;
};
type DataRow = User & Book & UserUID;
export interface ColumnDefinition<F extends keyof DataRow = keyof DataRow> {
valueFormatter?: (value: DataRow[F]) => string;
}
type ColumnDefinitionMap = {
[key in keyof DataRow]: ColumnDefinition<key>;
};
const columnDefinitions: ColumnDefinitionMap = {
admin: {
// typed correctly
valueFormatter: (value) => (value === true ? 'Admin' : 'User'),
},
id: {
// should be number | string via models from above
// currently is "never"
valueFormatter: (value) => typeof value === 'string' ? value : value.toString(),
},
};
Switching the DataRow
to a Union causes issues as well since it only gives me the id
column as that is the only common one between the three models.
Wondering if I'm missing something obvious or if there is a better way to approach this from the start.
Upvotes: 1
Views: 1633
Reputation: 329298
Unless you ever find yourself with some object which is a User
and a Book
and as UserId
, you don't want to use an intersection. The right data type is indeed the union of those types:
type DataRow = User | Book | UserUID;
Of course, as you noticed, you can't simply index into a union-typed object with a key that exists only in some but not all of the members of the union. Object types like interfaces are open and are allowed to have properties not known to the compiler; for example, the author
property of a value of type User
is not guaranteed to be missing. It could be any
value whatsoever.
There are two (related) approaches I can imagine to dealing with such a type.
One way to avoid "unexpected" any
properties entirely is to use a type like ExclusifyUnion
as explained in the answer to this question, where any unexpected properties from other members of the union are explicitly marked as missing in each member of the union, and therefore undefined
if you read them:
type DataRow = ExclusifyUnion<User | Book | UserUID>;
/* type DataRow = {
id: number;
admin: boolean;
email: string;
title?: undefined;
author?: undefined;
userId?: undefined;
} | {
id: number;
title: string;
author: string;
userId: number;
admin?: undefined;
email?: undefined;
} | {
id: string;
admin: boolean;
email: string;
title?: undefined;
author?: undefined;
userId?: undefined;
} */
You can see that now each member of the union explicitly mentions each property key, but in many of them the properties are optional-and-undefined
.
With this type, the rest of your code mostly goes through without a problem, except that any column that does not appear in all models will have a possibly undefined
type you have to deal with:
const columnDefinitions: ColumnDefinitionMap = {
admin: {
valueFormatter: (value) => (value === true ? 'Admin' : 'User'),
// (parameter) value: boolean | undefined
},
id: {
valueFormatter: (value) => typeof value === 'string' ? value : value.toString(),
// (parameter) value: string | number
},
};
So while the value
in id
's valueFormatter
is of type string | number
, the corresponding type for admin
is boolean | undefined
, because for all the compiler knows you will be trying to process the admin
field of a Book
. If you need to fix that, you can change the type of value
from DataRow[K]
to Exclude<DataRow[K], undefined>
using the Exclude
utility type.
The other approach is to just keep the original union, but use type functions to represent "a key from any member of the union" and "the type you get by indexing into an object of a union type with a key, if you ignore the members of the union that are not known to have that key":
type AllKeys<T> = T extends any ? keyof T : never;
type Idx<T, K> = T extends any ? K extends keyof T ? T[K] : never : never;
These are distributive conditional types which break unions up into individual members and then process them.
Then your types become this:
export interface ColumnDefinition<K extends AllKeys<DataRow>> {
valueFormatter?: (value: Idx<DataRow, K>) => string;
}
type ColumnDefinitionMap = {
[K in AllKeys<DataRow>]?: ColumnDefinition<K>; // making it partial
};
And now your code should also work as expected:
const columnDefinitions: ColumnDefinitionMap = {
admin: {
valueFormatter: (value) => (value === true ? 'Admin' : 'User'),
// (parameter) value: boolean
},
id: {
valueFormatter: (value) => typeof value === 'string' ? value : value.toString(),
// (parameter) value: string | number
},
};
I'm not sure which of those, if any, best suits your needs. But no matter what you do, you're going to want to be dealing with a union of some kind, not an intersection.
Upvotes: 1