Functional programming: list conditional branching/filtering (Javascript)

Viewed 456

I'm from an imperative programming background (Java) and starting to try understand FP concepts better. In particular conditional branching/filtering and how it can apply to streams/lists of data.

Here's a silly contrived example...we have a list of players and want to separate them into different lists based on their skill-levels. A basic imperative approach could be something like:

const excluded = []; // LOW skilled
const reserves = []; // only MEDIUM/HIGH skilled
const team = []; // only HIGH skilled

const allPlayers = [
    {
        name: 'personh1',
        skillLevel: 'HIGH'
    },
    {
        name: 'personh2',
        skillLevel: 'HIGH'
    },
    {
        name: 'personh3',
        skillLevel: 'HIGH'
    },
    {
        name: 'personm1',
        skillLevel: 'MEDIUM'
    },
    {
        name: 'personm2',
        skillLevel: 'MEDIUM'
    },
    {
        name: 'personl1',
        skillLevel: 'LOW'
    },
    {
        name: 'personl2',
        skillLevel: 'LOW'
    }
];

const maxTeamSize = 2;
const maxReservesSize = 2;

allPlayers.forEach(p => {
    if (p.skillLevel === 'HIGH') {
        if (team.length < maxTeamSize) {
            team.push(p);
        } else {
            reserves.push(p);
        }
    } else if (p.skillLevel === 'MEDIUM') {
        if (reserves.length < maxReservesSize) {
            reserves.push(p);
        } else {
            excluded.push(p);
        }
    } else {
        excluded.push(p);
    }
});

// functions defined elsewhere...
notifyOfInclusion(team.concat(reserves));
notifyOfExclusion(excluded);

I can accomplish this in a more functional manner: (using JS and the Ramda library):

team = R.slice(0, maxTeamSize, R.filter(p => p.skillLevel === 'HIGH', allPlayers));
reserves = R.slice(0, maxReservesSize, R.filter(p => (p.skillLevel === 'HIGH' || p.skillLevel === 'MEDIUM') && !R.contains(p, team), allPlayers));
excluded = R.filter(p => !R.contains(p, team) && !R.contains(p, reserves), allPlayers);

notifyOfInclusion(team.concat(reserves));
notifyOfExclusion(excluded);

But it seems very crude, repetitive and not very declarative. What is a better (more elegant/declarative) way of achieving something like this from a functional POV? Using Ramda in any answers would be a bonus but not essential. Thanks.

3 Answers

My version is a little more declarative than yours, but only a bit:

const skills = groupBy(prop('skillLevel'), allPlayers)
const ordered = flatten([skills['HIGH'], skills['MEDIUM'], skills['LOW']])
const team = filter(propEq('skillLevel', 'HIGH'), take(maxTeamSize, ordered))
const reserves = reject(propEq('skillLevel', 'LOW'), 
                        take(maxReservesSize, drop(length(team), ordered)))
const excluded = drop(length(team) + length(reserves), ordered)

This one assumes that you only want HIGH skilled players on the team, even if there are not enough of them to fill the spots. If you wanted to include MEDIUM-skilled players in that case, then you could replace filter(propEq('skillLevel', 'HIGH') with reject(propEq('skillLevel', 'LOW'). And if you wanted to fill to max[Team/Reserves]Size even if they don't match the skill levels, you can just remove the filter/reject calls. (That would also lead to much cleaner looking code.)

My initial attempt at a single function for all of them was pretty horrible, and it doesn't even quite work the way these do:

chain(
  selected => allPlayers => assoc(
    'excluded', 
    difference(allPlayers, concat(selected.team, selected.reserves)), 
    selected
  ),
  pipe(
    groupBy(prop('skillLevel')),
    lift(concat)(prop('HIGH'), prop('MEDIUM')),
    eligible => ({
      team: take(maxTeamSize, eligible),
      reserves: take(maxReservesSize, drop(maxTeamSize, eligible))
    }),
  )
)(allPlayers)

Of course you could also do this in a single reduce on the sorted list, and that may still be your best bet for your real problem, but I doubt that the interactions between the rules and the lengths of the various resulting lists will allow for really pretty code here either.

All this is available on the Ramda REPL.

You're looking for R.groupBy:

const allPlayers = [
    { name: 'personh1', skillLevel: 'HIGH' },
    { name: 'personh2', skillLevel: 'HIGH' },
    { name: 'personh3', skillLevel: 'HIGH' },
    { name: 'personm1', skillLevel: 'MEDIUM' },
    { name: 'personm2', skillLevel: 'MEDIUM' },
    { name: 'personl1', skillLevel: 'LOW' },
    { name: 'personl2', skillLevel: 'LOW'  }
];

const skillLevel = R.prop('skillLevel');

console.log(R.groupBy(skillLevel, allPlayers));
<script src="//cdn.jsdelivr.net/npm/ramda@0.25/dist/ramda.min.js"></script>

While that answer is not really functional, I would move some logic to some variable, which replresent som dependencies for using, if necessary, more groups with a uniform access and decision mechanism in which group the actual item has to go.

The logic after all is simple, just take the skillLevel as start level and iterate to the next lower level until a group is found which length is smaller than the given maximum for this group. Then push the item to this group.

const
    excluded = [], // LOW skilled
    reserves = [], // only MEDIUM/HIGH skilled
    team = [],     // only HIGH skilled
    allPlayers = [{ name: 'personh1', skillLevel: 'HIGH' }, { name: 'personh2', skillLevel: 'HIGH' }, { name: 'personh3', skillLevel: 'HIGH' }, { name: 'personm1', skillLevel: 'MEDIUM' }, { name: 'personm2', skillLevel: 'MEDIUM' }, { name: 'personl1', skillLevel: 'LOW' }, { name: 'personl2', skillLevel: 'LOW' }],
    maxTeamSize = 2,
    maxReservesSize = 2,
    temp = { HIGH: team, MEDIUM: reserves, LOW: excluded },
    lowerLevel = { HIGH: 'MEDIUM', MEDIUM: 'LOW' },
    max = { HIGH: maxTeamSize, MEDIUM: maxReservesSize, LOW: Infinity };

allPlayers.forEach(p => {
    var level = p.skillLevel;
    while (temp[level].length >= max[level]) {
        level = lowerLevel[level];
    }
    temp[level].push(p);
});

console.log(team);
console.log(reserves);
console.log(excluded);
.as-console-wrapper { max-height: 100% !important; top: 0; }

Related