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.
Playground link to code