Home > Blockchain >  Typescript discriminated union based on array membership
Typescript discriminated union based on array membership

Time:08-17

Is it possible to have something like this?

{
   roles: contains "admin",
   admin_details: string
} && {
   roles: contains "vendor",
   vendor_details: string
}

?

I figure probably not. What would be the cleanest way to get this sort of behavior, where a type has details depending on what roles they have?

The only way I could think of it was having something like:

({
   is_admin: true,
   admin_details: string
} | 
{ is_admin: false,
  admin_details: undefined
}) 
&& ({
   is_vendor: true,
   vendor_details: string
} | 
{ is_vendor: false,
  vendor_details: undefined
});

And adding those additional is_role field on role creation/change. But this is pretty verbose. Is it a good solution, or would you recommend something else?

Thanks!

edit: maybe a union between Admin and Vendor interfaces, with a type guard that examines array membership would be cleaner?

edit: I attempted to use the code in the first answer, but am having issues. See the updated playground here

another edit (too long for comments): playground here for failure when adding a third type to the RolesMap when using the Type Guard solution, see line 35

CodePudding user response:

We could generate a union of all possible combinations which would look like this in the end:

{
    roles: "admin"[];
    admin_details: string;
} | {
    roles: "vendor"[];
    vendor_details: string;
} | {
    roles: ("admin" | "vendor")[];
    admin_details: string;
    vendor_details: string;
}

To make things simple later on, we need a mapping between the role and its associated properties and types.

type RoleMap = {
  admin: { admin_details: string }
  vendor: { vendor_details: string }
}

To create this union, we need to generate all combinations of the roles tuple.

type AllCombinations<T extends string> = {
  [K in T]: K[] | AllCombinations<Exclude<T, K>> extends infer U extends any[]
    ? U extends any ? 
      (K | U[number])[] 
      : never
    : never
}[T]

type T = AllCombinations<keyof RoleMap>
// type T = "admin"[] | "vendor"[] | ("admin" | "vendor")[]

Now we can create the Roles union by distributing the permutation union and by creating a mapped type where each element of the roles tuple is mapped to its corresponding type in the RoleMap.

type UnionToIntersection<U> = 
  (U extends any ? (k: U)=>void : never) extends ((k: infer I)=>void) ? I : never

type Roles = AllCombinations<keyof RoleMap> extends infer U extends string[]
  ? U extends []
    ? never
    : ({ 
      roles: U
    } & UnionToIntersection<{
      [K in U[number]]: RoleMap[K & keyof RoleMap]
    }[U[number]]>) extends infer O ? { [K in keyof O]: O[K] } : never
  : never

The resulting type passes these simple test cases.

const a: Roles = {
  roles: ["admin"],
  admin_details: "0"
}

const b: Roles = {
  roles: ["vendor"],
  vendor_details: "0"
}

const c: Roles = {
  roles: ["vendor", "admin"],
  vendor_details: "0",
  admin_details: "0"
}

Playground


We can discriminate these union elements with a type guard.

type ExtractRoles<T extends { roles: any[] }> = Roles extends infer U extends { roles: any[] }
  ? U extends any
    ? T["roles"] extends U["roles"] 
      ? U
      : never 
    : never
  : never

function isRole<K extends keyof RoleMap>(roles: K[], obj: Roles): obj is ExtractRoles<{ roles: K[] }> {
  return (obj as any).roles.sort().join(',')===  roles.sort().join(',') 
}

if (isRole(["admin"], b)) {
  b.admin_details
}

if (isRole(["vendor"], b)) {
  b.vendor_details
}

if (isRole(["vendor", "admin"], b)) {
  b.vendor_details
  b.admin_details
}

Playground

CodePudding user response:

This examle matches your interface

// define all possible roles
const roles = ["admin", "vendor", "foo", "bar"] as const

type AllRoles = typeof roles

type RoleEnum = AllRoles[number] //"admin"| "vendor"| "foo"| "bar"
type RoleFlags<Role extends RoleEnum = RoleEnum> = `is_${Role}`
type RoleContextKeys<Role extends RoleEnum = RoleEnum> = `${Role}_details`

// flag key to context key and vice versa 
type RoleFlagToContextKey<R extends RoleFlags> = R extends `is_${infer Role extends RoleEnum}` ? RoleContextKeys<Role> : never
type ContextKeyToRoleFlag<R extends RoleContextKeys> = R extends `${infer Role extends RoleEnum}_details` ? RoleFlags<Role> : never

//type constraint for CreateRole<~>
type RoleInput = { [K in RoleFlags<RoleEnum>]?: boolean }

// wraps role input with context
type CreateRole<T extends RoleInput> =
  keyof T extends RoleFlags ?
  { [K in RoleFlagToContextKey<keyof T>]:
    T[ContextKeyToRoleFlag<K>] extends true
    ? string
    : undefined
  } & T : never


type A = CreateRole<{ "is_admin": true, "is_vendor": false }> //{admin_details: string; vendor_details: undefined; } & { is_admin: true; is_vendor: false;}
type B = CreateRole<{ "is_admin": true, "is_foo": false }> //{admin_details: string; foo_details: undefined; } & { is_admin: true; is_foo: false;}
type C = CreateRole<{ "is_vendor": true, "is_vendor": false }> // error
type D = CreateRole<{ "is_bar": true, "is_foo": false }> //{bar_details: string; foo_details: undefined; } & { is_bar: true; is_foo: false;}

playground

  • Related