typescript

How to declare a type with one of its properties being a dictionary where a key is excluded based upon the value of another property?


I've got an AuthStatus enum like:

enum AuthStatus {
  UNAUTHENTICATED,
  ONBOARDING,
  AUTHENTICATED
}

Now, I would like to declare a type as following:

type AuthType<T extends AuthStatus> = {
  accepted: T,
  navigateTo: Record<T, string>
}

where the navigateTo property should be a Record with the keys of the enum values, but excluding the one we choose as accepted.

For example:

/* 'exampleA' accepts 'UNAUTHENTICATED' users.
'UNAUTHENTICATED' should be excluded from the 'navigateTo' object.
*/
const exampleA: AuthType<AuthStatus> = {
  accepted: AuthStatus.UNAUTHENTICATED,
  navigateTo: {
    [AuthStatus.ONBOARDING]: '/onboarding',
    [AuthStatus.AUTHENTICATED]: '/',
  }
}

/* 'exampleB' accepts 'ONBOARDING' users.
'ONBOARDING' should be excluded from the 'navigateTo' object.
*/
const exampleB: AuthType<AuthStatus> = {
  accepted: AuthStatus.ONBOARDING,
  navigateTo: {
    [AuthStatus.UNAUTHENTICATED]: '/login',
    [AuthStatus.AUTHENTICATED]: '/',
  }
}

/* 'exampleC' accepts 'AUTHENTICATED' users.
'AUTHENTICATED' should be excluded from the 'navigateTo' object.
*/
const exampleC: AuthType<AuthStatus> = {
  accepted: AuthStatus.AUTHENTICATED,
  navigateTo: {
    [AuthStatus.UNAUTHENTICATED]: '/login',
    [AuthStatus.ONBOARDING]: '/onboarding',
  }
}

How to declare the AuthType to make sure the keys of the record excludes the accepted value?


Solution

  • I'd say you want AuthType<T> to be a union where for each member of the union T, there's a member of the union AuthType<T> where that member is accepted and where navigateTo has all keys in T except that member. You can write it as a distributive object type (as coined in microsoft/TypeScript#47109), a mapped type into which you index:

    type AuthType<T extends PropertyKey> = { [K in T]: {
        accepted: K,
        navigateTo: Record<Exclude<T, K>, string>
    } }[T]
    

    The important bit there is the Exclude utility type which filters union members. For AuthStatus that becomes

    type AuthTypeForAuthStatus = AuthType<AuthStatus>
    /* type AuthTypeForAuthStatus = {
        accepted: AuthStatus.UNAUTHENTICATED;
        navigateTo: Record<AuthStatus.ONBOARDING | AuthStatus.AUTHENTICATED, string>;
    } | {
        accepted: AuthStatus.ONBOARDING;
        navigateTo: Record<AuthStatus.UNAUTHENTICATED | AuthStatus.AUTHENTICATED, string>;
    } | {
        accepted: AuthStatus.AUTHENTICATED;
        navigateTo: Record<AuthStatus.UNAUTHENTICATED | AuthStatus.ONBOARDING, string>;
    } */
    

    as desired. Or for some other set of keys:

    type Test = AuthType<"a" | "b" | "c" | "d">;
    /* type Test = {
        accepted: "a";
        navigateTo: Record<"b" | "c" | "d", string>;
    } | {
        accepted: "b";
        navigateTo: Record<"a" | "c" | "d", string>;
    } | {
        accepted: "c";
        navigateTo: Record<"a" | "b" | "d", string>;
    } | {
        accepted: "d";
        navigateTo: Record<"a" | "b" | "c", string>;
    } */
    

    Your examples then work as written:

    const exampleA: AuthType<AuthStatus> = {
        accepted: AuthStatus.UNAUTHENTICATED,
        navigateTo: {
            [AuthStatus.ONBOARDING]: '/onboarding',
            [AuthStatus.AUTHENTICATED]: '/',
        }
    } // okay, etc
    

    Playground link to code