Clickbait title for the win.
Anyway, what's this about?
I've only been a programmer for about 4.5 years (as of writing this post) and no doubt there that we all grow after making enough mistakes that we reflect upon and correct.
True for everything in life but I'm going to keep it limited to programming
Or, helpers that are limited to a context or more specifically business logic. These are small functions that are very dependent on the data structure that's specific to the application you are building. To simplify, let's say I have a job listing app and I'm working on the page where these are to be visualized.
interface JobListing {
role: string
joiningDate: Date
name: string
// ... remaining fields
}
Assuming , we have the above structure / type for whatever I receive from the backend, I might have a formatter for the response to model it for consistency across the app or I might just use these fields as is. Though, often I'll need to write helpers that are very specific to this data.
For example, let's say I need to show all the Software Developer
roles with
the hex #18181b
then what?
import { Text } from '@components'
import { standardDate } from '@utils/date'
const getPositionStyledText = (role) => {
let color = '#000'
switch (role.toLowerCase()) {
case 'software developer': {
color = '#18181b'
break
}
}
return {
style: { color },
}
}
const JobRoleText = ({ role }) => {
const textStyle = getPositionStyledText(role)
return <Text style={textStyle.style}>{role}</Text>
}
const JobListingCard = ({job})=>{
return <>
<p>{job.name}</p>
<p>{standardDate(job.joiningDate)}</p>
<JobRoleText role={job.role}>
</>
}
Now we have 2 things that are very specific to this project, the component and
the helper for the component, you can move the function inside but then that
function would get redefined every time and will need to be inside a
useCallback
to avoid that so it's easier to just have it outside.
Back to the point, This is a bloc helper, or a business logic helper and these are often just limited to the app you write them for, moving them to other apps might need a lot of modification and so these are left alone and people generally start from scratch
As the name suggests, these are more geared towards being reused and don't really have business logic tied to them. Thing is, these are a little harder to write compared to the contextual one's because here you have to decide and design the API of the helper in a way to make it generic enough to be reused.
I'm going to give a small example and reuse the above styling helper again but this time written with a more generic API.
import { Text } from '@components'
type ColorMap = Record<string, string>
function createColorMap(definitions: ColorMap, defaultColor: string): string {
return toMatch => {
if (!colorMap[toMatch]) return defaultColor
return colorMap[toMatch]
}
}
// and the usage would look like so
const roleColor = {
'software developer': '#18181b',
}
const roleColorMatcher = createColorMap(roleColor, '#000')
const JobRoleText = ({ role }: { role: string }) => {
const textColor = roleColorMatcher(role.toLowerCase())
return <Text color={textColor}>{role}</Text>
}
before we go to the explanation
To the mounta.. explanation.
We asked 3 things, which will help you create most of the helpers you write.
Let's start with the 3rd question first, because that's important to understand.
Not all helpers can be made generic, and even if they can be, the API of the helper might not be as simple as the one with the context. What do I mean by that?
Back to examples, let's say I have a few cases where the provider
can be an
organization or a middleman or a startup each having the type
= 1 | 2
function getJobNameWithProvider(job) {
if (job.type === 1) {
if (job.org.type === 'startup')
return `${job.org.name} | ${job.name} (Startup)`
return `${job.org.name} | ${job.name}`
}
if (job.type === 2) return `${job.poster.name} | ${job.name}`
}
I could also use a switch
statement to add all this to a single string but for
now, this is complicated enough to explain what I'm trying to.
Here if I do make a generic helper, I'll be picking random fields based on
conditions and if I do make a generic helper it would look pretty much like the
createColorMap
function but it's the API that's troublesome.
function createConditionalPicker(pickerMap) {
return (passerObj, condition) => {
if (pickerMap[condition]) return pickerMap[condition](passerObj)
return null
}
}
// and usage would look like so
const jobNamePicker = createConditionalPicker({
1: job =>
job.org.type === 'startup'
? `${job.org.name} | ${job.name} (Startup)`
: `${job.org.name} | ${job.name}`,
2: job => `${job.poster.name} | ${job.name}`,
})
const job = {
type: 1,
org: {
name: 'BarelyHuman',
type: 'startup',
},
}
const jobName = jobNamePicker(job, job.type)
// BarelyHuman | undefined (Startup), since I haven't handled null cases above
You can technically use the createConditionalPicker
to even create the color
mapper above, but that's not the point.
So, here we have 2 things,
Looks like I almost wrote a monad though...
back to the concept, in cases like these it's easier to read and modify the original contextual helper than trying to make it generic. This will come with practice so you'll be making un-needed generic helpers quite often.
The 2nd question dictates how you design the API.
The above 2 helpers were curried functions since the setup data would be same and it'd make no sense to resend the entire object again and again when it's reference could be used and thus the returned function takes in the parameters that would actually access the reference point
If you are going to be working with different data every time, then you are better off with simpler functions.
If you are working with modifications on data, then we would need helpers that
allow pipes, for example the above name picker could be written with something
like @useless/asyncPipe
import asyncPipe from '@barelyhuman/useless/asyncPipe'
const jobDetails = await asyncPipe(
async () => await getJobDetails(jobId),
async job => {
if (job.type === 1) {
if (job.org.type === 'startup')
job.jobNameWithProvider = `${job.org.name} | ${job.name} (Startup)`
else job.jobNameWithProvider = `${job.org.name} | ${job.name}`
}
if (job.type === 2)
job.jobNameWithProvider = `${job.poster.name} | ${job.name}`
return job
}
)
here the asyncPipe
is the generic helper and we aren't really creating a
generic helper for the job name, instead we make the modifications on the source
data, which is how I would be doing the jobName field anyway but had to think of
something simple to explain the 3rd question.
Now, people would ask, why would you write these functions inside a pipe? Good question.
The point of using a pipe to make sure the structure is modifiable, because the pipe assume a set of data to be passed down at all times. This isn't close to the original functional programming pipe but more to how coffeescript implements pipes.
The above in production code looks like this.
async function addProviderNameToJob(job) {
if (job.type === 1) {
if (job.org.type === 'startup')
job.jobNameWithProvider = `${job.org.name} | ${job.name} (Startup)`
else job.jobNameWithProvider = `${job.org.name} | ${job.name}`
}
if (job.type === 2)
job.jobNameWithProvider = `${job.poster.name} | ${job.name}`
return job
}
const jobDetails = await asyncPipe(
async () => await getJobDetails(jobId),
addProviderNameToJob
)
At this point, the addProviderNameToJob
is optional, I can remove it and add
it anywhere in the pipe and still expect the same result because you'd
conceptually pass the same job down the pipe. The asyncPipe
from @useless
isn't tightly tied to a source for other reasons but based on functional
concepts. You'd have one source and multiple sinks for that source.
The sinks are what consume the source, make modifications to it and return it. I can add another modification in the middle if it does the same thing, consumes the source, modifies it and returns it
async function addProviderNameToJob(job) {
if (job.type === 1) {
if (job.org.type === 'startup')
job.jobNameWithProvider = `${job.org.name} | ${job.name} (Startup)`
else job.jobNameWithProvider = `${job.org.name} | ${job.name}`
}
if (job.type === 2)
job.jobNameWithProvider = `${job.poster.name} | ${job.name}`
return job
}
async function addBarelyHumanToJob(job) {
job.isFromBarelyHuman = true
return job
}
const jobDetails = await asyncPipe(
async () => await getJobDetails(jobId),
addBarelyHumanToJob,
addProviderNameToJob
)
Pretty stupid for an example, but I hope you get the point, my business logic can be separated in chunks and still be added or removed at will.
Don't be fooled, you can do all that without even using asyncPipe
but it's a
little more structured for my mental model.
Last question, what is the base operation. The base operation for the color mapper was to compare a string to another string (switch cases or if case) which can be moved into comparing a value in map.
You basically move out the base operation and write that into a generic function and then move the data dependent decisions out to the developer using the API.
Combine this with the answers to the other 2 questions and you'll have an helper design in your head or on the paper. Then run a few tests and voila, generic helpers!!
It's always going to be easier to write business logic specific helpers but if something can be split into a generic helper which you see happening in most projects that you write, spend a little more time and make a generic helper out of it.
Do keep in mind that not everything needs to be generic, some things are easier to read and modify when left with their context.