Avoiding Obj.magic idioms?

what are some ways to avoid Obj.magic sprinkled all over your code? I always try hard to avoid this, but also always end up with Obj.magic all over the place in my rescript code bases.

as a habit, (and having worked in some other languages) I always put a SAFETY: comment above any black magic in my code (e.g., Obj.magic), but i’d still like to have less Obj.magic in my code. here are some examples just to show you how i’m using it:


  // Load env files in order, applying overrides if enabled
  paths->Array.forEach(path =>
    switch Dotenv.config({path, processEnv: dict{}, quiet: true}) {
    | {parsed, error: None} =>
      if parsed->Dict.isEmpty {
        warn(`File did not provide any env vars: ${path}`)
      }
      applyVars(parsed)
      loadedFilesMut->Array.push(path)

    | {error: Some(e), _} =>
      // SAFETY: There's no scenario where this crashes hard. Worst case, code will just be None
      let code = (Obj.magic(e)["code"] :> option<string>)
      if code != Some("ENOENT") {
        JsExn.throw(e)
      }
    }

above, e is a JsExn.t, which under certain circumstances can have a code field. what do?

here’s another snippet:

    let v = Obj.magic(val)
    let vt = typeof(v)

    let tracked = switch vt {
    | #object if v != Null.null =>
      seen->Set.add(v)
      true
    | _ => false
    }

    let out = switch vt {
    | #undefined => JSON.Encode.null
    | #boolean => JSON.Encode.bool(v)
    | #number => JSON.Encode.float(v)
    | #bigint => encodeBigInt((v :> BigInt.t))
    | #string => JSON.Encode.string(v)
    | #symbol => encodeSymbol((v :> Symbol.t))
    | #object if v == Null.null => JSON.Encode.null
    | #object if Array.isArray(v) =>

could almost be done with a discriminated union, but not quite. ideas?

a third one:

  let stringifySafe = v =>
    JSON.stringifyAny(
      v,
      ~replacer=Replacer(
        (_, v) =>
          switch typeof(v) {
          | #bigint =>
            let bi = (Obj.magic(v) :> BigInt.t)
            JSON.Encode.string(BigInt.toString(bi) ++ "n")
          | #symbol =>
            let sym = (Obj.magic(v) :> Symbol.t)
            JSON.Encode.string(sym->Symbol.toString)
          | _ => v
          },
      ),
    )

come up with ideas or lecture me on this, i really dislike that i end up with all these Obj.magic sprinkled throughout

I think the issue in your code comes from the fact you treat all these values as unknown or whatever the typescript type is supposed to be and then try to narrow down their types at runtime.

But ReScript is not like typescript, you’re not supposed to type all the possible cases of a given variable, you just encode the characteristics you need of these types, so you can use more constrained types in your bindings and you won’t need all these Obj.magic, for example:

module Dotenv = {
  type configOptions = {
    path?: string,
    processEnv?: dict<string>,
    quiet?: bool,
  }
  @unboxed type configCode = ENOENT | Other(string)
  type configError = private {code?: configCode}
  type configOutput = private {
    parsed?: dict<string>,
    error?: configError,
  }
  @module("dotenv")
  external config: configOptions => configOutput = "config"
}

// Load env files in order, applying overrides if enabled
let paths = [".env", "dev.env"]
let loadedFilesMut = []
let applyVars = _s => ()
paths->Array.forEach(path =>
  switch Dotenv.config({path, processEnv: dict{}, quiet: true}) {
  | {parsed} if parsed->Dict.isEmpty =>
    Console.warn(`File did not provide any env vars: ${path}`)
    loadedFilesMut->Array.push(path)
  | {parsed} =>
    applyVars(parsed)
    loadedFilesMut->Array.push(path)
  | {error: {code: ENOENT}} => ()
  | {error} => JsExn.throw(error)
  | _ => ()
  }
)
1 Like

thank you a ton for this, exactly the kind of feedback i was looking for!

2 Likes

You can make your own versions of Obj.magic that have more restrictions when necessary:

type t
external coerce: 'a => t = "%identity"

Or only coerce after verification:

let rawCode: unknown = Obj.magic(e)["code"]
if Js.typeof(rawCode) === "number" {
  let code: int = rawCode->Obj.magic
  // do something
}

Or make accessors instead of using the js[field] syntax:

@get external code: 'a => option<int> = "code"

// when null is possible
@get @return(nullable) external code: 'a => option<int> = "code"

// with some verification
let code = t => t->code-Option.flatMap(code => Number.isInteger(code) ? Some(code) : None)

// or when ReScript isn't simple enough
let code: 'a => int = %raw(`t => t?.some?.nested?.property?.code ?? 0`)

Lastly, use a JSON decoding library to convert unknown values into ReScript records.

1 Like