1

I'm trying to type a function where an argument type should be inferred from a value in a nested object. How do I infer the function arguments type that is inside a deeply nested object?

Example:

export enum Role {
  USER = 'user',
  ADMIN = 'admin',
  OWNER = 'owner',
  PRIMARY_OWNER = 'primaryOwner',
}

// Add as needed. Formatted as 'resource:action'?
export type Ability =
  | 'users:create'
  | 'users:edit'
  | 'reports:view'
  | 'settings:view';

type StaticAbilities = readonly Ability[];

type DynamicAbility = (data: any) => boolean;

type DynamicAbilities = { readonly [key in Ability]?: DynamicAbility };

export type Abilities = {
  readonly [R in Role]?: {
    readonly static?: StaticAbilities;
    readonly dynamic?: DynamicAbilities;
  }
};

/**
 * A configuration object containing allowed abilities for specific roles.
 */
export const ABILITIES: Abilities = {
  user: {
    dynamic: {
      // THIS IS AN EXAMPLE OF DYNAMIC RULES
      'users:edit': ({
        currentUserId,
        userId,
      }: {
        /** Current users ID */
        currentUserId: string;
        /** User ID trying to be edited */
        userId: string;
      }) => {
        if (!currentUserId || !userId) return false;
        return currentUserId === userId;
      },
    },
  },
  admin: {
    static: ['reports:view', 'settings:view'],
  },
  owner: {
    static: ['reports:view', 'settings:view'],
  },
  primaryOwner: {
    static: ['reports:view', 'settings:view'],
  },
};

export const can = ({
  role,
  ability,
  data,
}: {
  role: Role;
  ability: Ability;
  data?: any;
}): boolean => {
  const permissions = ABILITIES[role];

  // Return false if role not present in rules.
  if (!permissions) {
    return false;
  }

  const staticPermissions = permissions.static;

  // Return true if rule is in role's static permissions.
  if (staticPermissions && staticPermissions.includes(ability)) {
    return true;
  }

  const dynamicPermissions = permissions.dynamic;

  if (dynamicPermissions) {
    const permissionCondition = dynamicPermissions[ability];

    // No rule was found in dynamic permissions.
    if (!permissionCondition) {
      return false;
    }

    return permissionCondition(data);
  }

  // Default to false.
  return false;
};

Given a specific role and ability, I want to type data in can() to be the function arguments type defined in ABILITIES if it exists. If it doesn't exist, then I don't want data to be required.

I expect the type of data to be a required type of { currentUserId: string; userId: string }, when role is Role.USER and ability is 'users:edit'.

1 Answer 1

2

You can do some conditional type magic to extract the appropriate parameter types provided ABILITIES is typed with the actual type of the object literal (not just Abilities). We can use an extra function to help the compiler infer the correct type.

export enum Role {
    USER = 'user',
    ADMIN = 'admin',
    OWNER = 'owner',
    PRIMARY_OWNER = 'primaryOwner',
}

// Add as needed. Formatted as 'resource:action'?
export type Ability =
    | 'users:create'
    | 'users:edit'
    | 'reports:view'
    | 'settings:view';

type StaticAbilities = readonly Ability[];

type DynamicAbility = (data: any) => boolean;

type DynamicAbilities = { readonly [key in Ability]?: DynamicAbility };

export type Abilities = {
    readonly [R in Role]?: {
        readonly static?: StaticAbilities;
        readonly dynamic?: DynamicAbilities;
    }
};

function createAbilities<A extends Abilities>(a: A) {
    return a;
}
export const ABILITIES = createAbilities({
    user: {
        dynamic: {
            // THIS IS AN EXAMPLE OF DYNAMIC RULES
            'users:edit': ({
                currentUserId,
                userId,
            }: {
                /** Current users ID */
                currentUserId: string;
                /** User ID trying to be edited */
                userId: string;
            }) => {
                if (!currentUserId || !userId) return false;
                return currentUserId === userId;
            },
        },
    },
    admin: {
        static: ['reports:view', 'settings:view'],
    },
    owner: {
        static: ['reports:view', 'settings:view'],
    },
    primaryOwner: {
        static: ['reports:view', 'settings:view'],
    },
});
type ExtractDynamicParameter<R extends Role, A extends Ability> = typeof ABILITIES[R] extends { dynamic: Record<A, (p: infer P) => boolean> } ? { data : P } : { data?: undefined}
export const can = <R extends Role, A extends Ability>({
    role,
    ability,
    data,
}: {
    role: R;
    ability: A;
} & ExtractDynamicParameter<R, A>): boolean => {
    const permissions = ABILITIES[role as Role] as Abilities[Role]; // Needed assertions 

    // Return false if role not present in rules.
    if (!permissions) {
        return false;
    }

    const staticPermissions = permissions.static;

    // Return true if rule is in role's static permissions.
    if (staticPermissions && staticPermissions.includes(ability)) {
        return true;
    }

    const dynamicPermissions = permissions.dynamic;

    if (dynamicPermissions) {
        const permissionCondition = dynamicPermissions[ability];

        // No rule was found in dynamic permissions.
        if (!permissionCondition) {
            return false;
        }

        return permissionCondition(data);
    }

    // Default to false.
    return false;
};

can({ role: Role.USER, ability: "users:edit", data: { currentUserId: "", userId: "" } }) // ok 
can({ role: Role.USER, ability: "users:edit", data: {} }) // err
can({ role: Role.USER, ability: "users:edit" }) // err
Sign up to request clarification or add additional context in comments.

2 Comments

I'd like to make can({ role: Role.USER, ability: 'users:edit' }); fail for missing data when it's needed. I assume the way of doing that would be to do a function overload of some sort?
@erictaylor changed the answer to work like the request above.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.