nvitaterna
nvitaterna

Reputation: 463

Typescript Column Definitions

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

Answers (1)

jcalz
jcalz

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.

Playground link to code

Upvotes: 1

Related Questions