Static names in Racket

@notjack.space

In Racket, many APIs expect you to pass in a string or symbol that's the textual form of some variable or function name. For example, consider this code:

(define (delete-user-data user-id)
  (unless (admin? (current-user))
    (raise-arguments-error 'delete-user-data "not authorized"))
  (delete-posts user-id)
  (delete-comments user-id)
  (delete-files user-id))

(module+ test
  (test-case "delete-user-data"
    (define fake-user-id (setup-fake-user))
    (delete-user-data fake-user-id)
    (check-false (user-exists? fake-user-id))))

There are four places in this code where the text define-user-data occurs:

  • The function signature, (define (delete-user-data user-id) ...);
  • As the name argument in (raise-arguments-error 'delete-user-data "not authorized")), which is used to construct an error message;
  • As the test case name in (test-case "delete-user-data" ...), which is used when reporting a test failure;
  • And finally, at the (delete-user-data fake-user-id) call site within the test case.

Two of these occurrences are as a bound identifier: a statically-known variable that the compiler has associated with a value. The other two, however, are some flavor of raw text data: either a symbol ('delete-user-data) or a string ("delete-user-data"). These textual occurrences go unnoticed by tools like the compiler and the DrRacket editor.

When working with bound identifiers, typos cause compile failures. Additionally, right-clicking on a bound identifier in DrRacket offers the option to rename the identifier and all other uses of it in the source file. Neither of these statements is true for names used within string and symbol constants: typos aren't caught, and renaming ignores them. As code is refactored over time, this can lead to confusing messages in errors and test failures.

Solution: static name macros

To combat this in my library Rebellion, I wrote a small macro called name. Well, technically two macros: name and name-string. The expression (name foo) expands to the symbol constant 'foo, and (name-string foo) expands to the string constant "foo". But there's a trick: if foo refers to a nonexistent variable, it fails to expand, causing a compile failure.

Additionally, these macros use Racket's syntax property system to record a 'disappeared-use property. This property informs the Racket macro expander that the foo identifier within (name foo) (or (name-string foo)) should be considered a usage of the variable foo, even though the macro's expansion doesn't produce code that refers to the variable. DrRacket and other tools look for disappeared uses when searching for variables, which allows DrRacket's renaming feature to rename identifiers that don't appear in the fully expanded code.

Putting these macros to use in the above code, here's what we get:

(define (delete-user-data user-id)
  (unless (admin? (current-user))
    (raise-arguments-error (name delete-user-data) "not authorized"))
  (delete-posts user-id)
  (delete-comments user-id)
  (delete-files user-id))

(module+ test
  (test-case (name-string delete-user-data)
    (define fake-user-id (setup-fake-user))
    (delete-user-data fake-user-id)
    (check-false (user-exists? fake-user-id))))

All four occurrences of delete-user-data are tracked by the compiler and editors. Renaming one will rename them all, and compiling successfully serves as proof that there are no typos. But there's a few downsides:

  • This is yet another utility library I'd have to import everywhere. Given that I'd use it for almost every unit test, most files I write would require it.
  • Editors highlight 'foo and "foo" differently from variables to draw attention to the fact that they're constant literal data, not code. But this macro-based approach hides constant data within code via macro expansion. Arguably, this makes it more difficult to visually skim the code.
  • It's slightly more verbose. For test cases this doesn't bother me much, because the test case name is almost always on a line of its own with nothing else but the text (test-case preceding it, making horizontal space abundant. But with error checking code like raise-arguments-error, it can lead to some mildly irritating extra linebreaks. This issue is minor, all things considered, but the sheer quantity of use sites for these macros compounds the issue.
  • I don't like the way (name-string foo) reads grammatically. I think if I were to do it again, I would have called that macro name-as-string instead of name-string.

So far, I've just kept this in a private utility library within the internals of Rebellion. You can see the gory details here. Maybe someday this could be a package on its own, though it seems so small and so widely usable that perhaps Racket's main distribution is a better place to put it.

notjack.space
Jacqueline

@notjack.space

rapid unscheduled torment nexus disassembly expert — nonbinary, she/they — writes Racket — @doitwithalambda on twitter

Post reaction in Bluesky

*To be shown as a reaction, include article link in the post or add link card

Reactions from everyone (0)