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