How can I generate a type of string literals from an objects keys and values returned by a function?

Viewed 138

I am trying to create a type from a readonly object, i.e.

const Actions = {

  'user.crud': ['user.create', 'user.read', 'user.update', 'user.delete'],

} as const

type ActionsType = keyof typeof Actions | typeof Actions[keyof typeof Actions][number]

The above works nicely and sets up the type ActionsType to the string literals ('user.crud', 'user.create', etc..).

However, the Actions object above is very simplistic, instead, I really need to generate the Actions via functions. When I port the above over to being generated by a function, i.e.

// set up a function to generate all actions for the passed role
function getActions (role: string): Record<string, string[]> {

    return {
        [`${role}.crud`]: [`${role}.create`, `${role}.read`, `${role}.update`, `${role}.delete`],
    }

}

// generate the Actions from a function
const ActionsFromFunction = {

  ...getActions('user'),

} as const

// set up the Actions from a readonly object with values generated by getActions()
type ActionsFromFunctionType = keyof typeof ActionsFromFunction | typeof ActionsFromFunction[keyof typeof ActionsFromFunction][number]

the type ActionsFromFunctionType is no longer set to the string literals. Instead it is set to: string | number and in turn type tests fail as any string is accepted.

I've put together a demo of the above:

Playground

Is there a way of generating the Actions object via a function, whilst still maintaining the string literals within the type?

2 Answers

Your goal is only achievable through typescript Template literal types. They are not supported in typescript 4.0, but will be available in 4.1 version.

This is how you could do it with typescript 4.1

type CrudOperations<ROLE extends string> = [`${ROLE}.create`, `${ROLE}.read`, `${ROLE}.update`, `${ROLE}.delete`];

type GetActionsResult<ROLE extends string> = string extends ROLE // check if we can infer type
  ? { [k: string]: string[] } // if type is not inferable
  : { [K in `${ROLE}.crud`]: CrudOperations<ROLE> };

function getActions<ROLE extends string>(role: ROLE): GetActionsResult<ROLE> {
    return {
      [`${role}.crud`]: [`${role}.create`, `${role}.read`, `${role}.update`, `${role}.delete`]
    } as GetActionsResult<ROLE>;
}

// falls back to { string: string[] } structure
const actions = getActions('admin' as string);

// generate the Actions from a function
const ActionsFromFunction = {
  ...getActions('user'),
  ...getActions('orders'),
}

Playground link

You can also write getActions without explicit types by inference:

function getActions<K extends string>(role: K) {
  const actions = {
    [`${role}.crud`]: [`${role}.create`, `${role}.read`, `${role}.update`,
    `${role}.delete`],
  } as const
  return actions as Record<K,typeof actions[K]>
}
Test it:
const ActionsFromFunction = {
  ...getActions('user'),
} 
// { user: readonly ["user.create", "user.read", "user.update", "user.delete"];}

const ActionsFromFunction2 = {
  ...getActions('user' as string),
} 
// { [x: string]: readonly [`${string}.create`, ..., `${string}.delete`]; }

type ActionsFromFunctionType =
  | keyof typeof ActionsFromFunction
  | typeof ActionsFromFunction[keyof typeof ActionsFromFunction][number]
// "user" | "user.create" | "user.read" | "user.update" | "user.delete"

Note, that getActions won't be accurate, if your role has a union string type:

const ActionsFromFunction3 = {
  ...getActions('user' as 'user' | 'admin'),
} // { user: ...; admin: ...; }

You might write getActions as distributed conditional type, if needed:

return actions as K extends any ? Record<K,typeof actions[K]> : never
const ActionsFromFunction3 = {
  ...getActions('user' as 'user' | 'admin'),
}
/* 
| { user: readonly ["user.create", "user.read", "user.update", "user.delete"];} 
| { admin: readonly ["admin.create", "admin.read", "admin.update", "admin.delete"]; } 
*/

Playground

Related