Search code examples
typescripttypestypescript-typingstypescript-generics

Typescript generic type: map list of records by it's nested attribute


I'm writing a custom type called "RecordsByType", left as unknown in the following snippet.

I have types that represent database records and each record has its type defined in a nested attribute ["attributes"]["type"], see Account for example. Unfortunately, this is the only thing in the snipped that cannot be changed. Ofc there will be many more "RecordTypes" than just Account or User.

type RecordTypeName = 'Account' | 'User';
type RecordType = Account | User;
type RecordKey = keyof DbRecord | keyof Account | keyof User;

interface DbRecord {
    attributes: {
        type: RecordTypeName;
    };
}

interface Account extends DbRecord {
    attributes: {
        type: 'Account';
    };
    name: string;
}

interface User extends DbRecord {
    attributes: {
        type: 'User';
    };
    email: string;
}

const account1: Account = { attributes: { type: 'Account' }, name: 'A1' };
const account2: Account = { attributes: { type: 'Account' }, name: 'A2' };
const user: User = { attributes: { type: 'User' }, email: 'email@email.com' };

type RecordsByType = unknown;

// Next line is initialised with some values to show what the type should look like, 
// but we should be able to initialise it without any values as well,  
// i.e. just '= {};' should work too.
const recordsByType: RecordsByType = {
    Account: [account1, account2],
    User: [user]
};

// Next line should compile. Should be Account[] and not generic DbRecord[] nor RecordType[].
const accounts: Account[] = recordsByType['Account'] || [];

// Next line should not compile as user is not type of Account.
recordsByType['Account']?.push(user);

This question seems a lot related, but my key "attribute" is nested and does not have to be this generic.

1. attempt
First I tried this type, but the problem is that the value of "type" does not match the list of records, so the result will always be incorrectly the generic DbRecord[].

type RecordsByType = Partial<Record<DbRecord['attributes']['type'], DbRecord[]>>;
// => Type DbRecord[] is not assignable to type Account[]

2. attempt
So I tried to match the "type" value with the value in the record, but again similar issue.

type RecordsByType<
    T extends K['attributes']['type'],
    K extends RecordType
> = Partial<Record<T, K[]>>;
// Next line would not compile as the result is RecordType[] and not Account[]
const accounts: Account[] = recordsByType['Account'] || [];

Thoughts
I need to somehow define that each record's ["attributes"]["type"] value matches the "key" value and somehow be able to return a list of specific implementations per each key instead of the generic type.


Solution

  • To do this, you'll want to start with a union of the possible types (Acccount, User, etc.):

    type AllTypes = Account | User /* | SomethingElse | AnotherThing etc. */;
    

    Then we can define RecordsByType as a mapped type mapping the type property types as keys to arrays of the relevant type, which we get by extracting anything assignable to { attributes: { type: Key } } from AllTypes:

    type RecordsByType = {
        [Key in AllTypes["attributes"]["type"]]?: Extract<AllTypes, { attributes: { type: Key } }>[];
    };
    

    Then this compiles as desired:

    const accounts: Account[] = recordsByType["Account"] || [];
    

    ...and this fails as desired:

    recordsByType["Account"]?.push(user);
    

    Playground link


    (FWIW, in modern code I'd use nullish coalescing [??] over logical OR [||] in recordsByType["Account"] || [], now that we have it: recordsByType["Account"] ?? [])