Avoiding Primitive Obsession with JS binds

I’m making a discord bot with rescript

It felt like I shouldn’t reinvent the wheel and just go with discord.js npm library to simplify things. I’ve also been working on my strict typing skills, specifically primitive obsession. I’ve run into a block.

When I receive an event from the bot, i use an event type to return a custom callback.

type snowflake = Snowflake(string)
type guild = Guild({id: snowflake})

type event =
  | Ready(unit => unit)
  | GuildCreate(guild => unit)

let onEvent = (client, event) => {
  switch client {
  | Client(client) =>
    switch event {
    | Ready(callback) => client->on("ready", callback)
    | GuildCreate(callback) => client->on("guildCreate", callback)
    }
  }
}

Handling the objects that are returned is where the problem is. If I want the id field of guild I have a few options

//Option 1: getter
@get external getGuildId: guild => string = "id"

client->Discord_Client.onEvent(
  GuildCreate(
    (guild) => guild->{
      let guildId = guild -> getGuildId
      Guild({id: Snowflake(guildId)}
}
  )
)

//Option 2: Primitive obsessed type
type guild = {id: string, name: string}

client->Discord_Client.onEvent(
  GuildCreate(
    (guild) => guild
  )
)

//Option 3: two types, map one to the other
type guildJs = {id: string}
type guild = Guild({id: snowflake})

let mapGuild = (guild: guildJs) => Guild({id: Snowflake(guild.id)})

client->Discord_Client.onEvent(
  GuildCreate(
    (guild: guildJs) => guild->mapGuild
  )
)

Option 1 will have to make an @get for every field I want, which my guess is the way that this expected to be solved (with bindings)

Option 2 is the problem I’m trying to avoid

Option 3 just feels wrong to have two types for effectively the same type

Advice on making this more elegant

In your case luckily ReScript supports binding to event listeners in a more type-safe way. I would use two concepts:

  1. Abstract type for guild ID so they can’t be just created out of thin air
  2. Type-safe event listener binding

I would probably do something like:

// Discord_Client.res

type t
type guildId
type guild = {id: guildId, name: string}

@send
external on: (
  t,
  @string [
    | #ready(unit => unit)
    | #guildCreate(guild => unit)
  ]
) => unit = "on"

This takes care of the ‘primitive’ issue by ensuring guild IDs can’t be made up by anyone. Because of abstract types, we don’t even need a variant type to do this.

5 Likes

Nice. This helps a lot with the event listener binding. Thanks

However, the guild type still suffers from primitive obsession.

It might be personal preference, but using Variant types helps to write more correct code. I like when my compiler complains when my function arguments are wrong or in incorrect order.

Just using an abstract here won’t give me the assurance a Variant would

An abstract type will absolutely give that assurance :slightly_smiling_face: you should test it yourself. Try passing in an abstract guildId to a function that expects a string, or vice-versa. In fact the assurance you get is stronger than with just a variant, which can be unwrapped and rewrapped by callers trying to make it fit into the required type.

1 Like

Using an abstract type is not guaranteeing that guildId is a string though. I could technically return anything as a guildId type and introduce runtime bugs?

Also, what about the name field, should that also be an abstract type?

It would guarantee that guildId is whatever format the Discord API you’re using returns. If that is a string, then so will be guildId.

You could use an abstract type for name too, but imho there’s such a thing as ‘avoiding primitive obsession’ obsession. After a certain point is the payoff worth it?

1 Like

I toyed with discord.js bindings myself a few years ago, I didn’t get very far so I don’t know if my old repo will be any use… but if you want to take a look :slight_smile:

I don’t see how variant is any better at compile errors than a record or an abstract type. The abstract type can’t be any random value - creating one is completely controlled by the surrounding code. And records have structure, they aren’t primitive.

I remember some speculation around creating variant instances through object bindings by customising the TAG field at runtime, but I’m not sure that would really solve your problem.

Option 3 is how I normally do it when faced with the sorts of types you’re working with. This doesn’t feel wrong to me; the values JS is able to create are very different to the one I want to use in my code even if the field names are the same. Types are cheap and the JS type can be hidden behind an interface.

3 Likes