What is your approach to access control in your F# solutions?

Prompted by the discovery that C# now has a new access modifier file, I was reminded that I always wanted to see how people approach managing access to modules and functions in F#. We have the standard access modifiers: public, internal, and private. I like to expose as few implementation details as possible, so I often opt for private. Quite often, though, I end up bumping the visibility to internal because, as it turns out, I would like to write a test for something that was not accessible. With the InternalsVisibleTo attribute, all is fine.

As the codebase grows, though, this results in a lot of things annotated as internal, which makes me wish the default visibility would rather be internal than public, just like in C# or Swift (was that ever considered?).

Alternatively, one could make use of the signature files. They would allow me to specify what the API surface is exposed. Signature files bring additional benefits like improved compilation speed etc. but come for the price of some mildly annoying duplication (I defined my type in the signature file, why do I need to do it again in the implementation?). I would probably be willing to pay this price, but if I understand things correctly, this would still not allow me to test the functions which are not exposed via the signature file.

In the end, I come back to sprinkling internal here and there as it’s not that big of a deal, but I suspect I’m probably not approaching this correctly. Curious to hear other opinions!

2 Likes

I often find myself pretty happy with the default public access. I do tend to do functional programming though, and so functions are pure unless they’re close to the entrypoint (and thus at the bottom of the fsproj and visible to hardly anything else). When doing functional programming, I think access control becomes a lot less of a problem than in traditional OO programming. In traditional OO, where methods are often interacting with and modifying state, you can end up with a lot of methods that you can easily call incorrectly by calling things in the wrong order or leaving things in a broken state. In FP, if you have a pure function (especially a pure, total function that can’t throw exceptions), then you can’t really call it incorrectly. So access control doesn’t really stop bugs, it’s more just architectural in that if you call a function that’s an implementation detail, then you have to continue supporting that function. That or refactor jobs can be larger because you have to alter anything that relies on the implementation detail. It can still be important, but maybe less important than it is in OO.

Also, in F#, it’s so easy to define nested functions that I’ll often put implementation details in a nested function as a way of hiding it. That would make it completely hidden, even from tests, but I’ve heard some compelling arguments that implementation details shouldn’t be tested directly with automated unit tests anyway.

One place that I will definitely use access control is for a “smart constructor” that uses validation. So something like

// so EmailAddress can't be constructed outside of the current module/namespace
type EmailAddress = private EmailAddress of string

module EmailAddress = 
  // and here's the way you should construct an EmailAddress from everywhere else in the application
  let create (addr:string) = 
    if isValidEmail addr 
    then Some (EmailAddress email)
    else None

  let stringify (EmailAddress addr) = addr

I’ve never found signature files worth the effort.

All that said, I don’t think you’re approaching it incorrectly. I suspect that a lot of F#ers are functional programmers, and I suspect that functional programmers just don’t care as much about controlling access to their functions since the ramifications are less severe. But choosing to mark functions as internal that really probably should be internal I think is good discipline that maybe just isn’t followed by me (and others?) as much as it is in, for example, the C# community.

2 Likes

Thanks for your answer @ntwilson! Interesting, I never thought of this as something that differentiates FP from object programming. It’s true that my main gripe with this topic is not really about preventing incorrect use of functions, but more about having a clean API surface and mechanisms that force me to think about what I’m exposing so that suddenly some consumer is not dependent on an implementation detail of mine.

As you say, nested functions are one way to deal with this, quite often they make impact the code readability imho. It’s cool to have a small local helper function but once you have a couple of them, the distance between a function’s declaration and its actual body makes reading code a bit cumbersome to me.

Out of curiosity I had a brief look at how Haskell does this. Indeed, it does not seem to have any notion of access modifiers. It does, however, allow you to specify in your module declaration which functions or data types are “exported” (A Gentle Introduction to Haskell: Modules).

I think this might be the thing that does not sit well with me when I have the default public visibility in F#. I do like the deliberate decision of exposing something to the consumer. In Haskell you do that using the module difinition, in TypeScript you export the things you want other areas of your program to consume. I have the impression that smart constructors (we use them a lot and like them!) are liked exactly for this reason: they give you control over how some consumer interacts with the API you exposed. Flipping the defaults would force me to think more about what I’m exposing.

1 Like

I agree with the approach. Ideally a definition will be locally scoped, and if not then private, and if not then internal, and if not then public. While I agree with ntwilson that scoping is not essential for reducing bugs in functional programming, it increases intelligibility of a codebase, and together with the linear ordering of F#, reduces the number of things you have to understand in order to understand an object which lies somewhere in the middle of a codebase. It also reduces the number of tools a programmer has available when coding which increases productivity if those tools are chosen well.

I agree about the cons of signature files and I think that reference assemblies (whose implementation are in progress in F#) are going to be the most impactful thing for increasing compilation speed.