Stream: ideas

Topic: Restrict optional fields to function calls


view this post on Zulip Johannes Maas (Mar 10 2024 at 10:14):

I keep thinking about the problem of how to design the optional fields that is being discussed in multiple topics. I'm currently on mobile and haven't kept up with every detail, so hopefully the following idea is new and hasn't yet been discussed, but I wanted to share it before I forget it.

As far as I understand the only place we want to allow optional fields is for function calls. Records themselves shouldn't really have optional fields, only when they are used to provide named arguments to functions. The current proposals (that I've seen) tie the syntax for optional fields to the general record syntax, so that it can principally also be used outside of function calls.

That led me to think about whether there is a way to tie the optional fields syntactically to function calls so that they cannot be used elsewhere. I jad this idea:

\argument ? { optionalField: 2 } ->
sum = argument.requiredField + argument.optionalField

The type could be displayed as { requiredField : U8, optionalField ? U8 } as before.

This syntax could be restricted to only function calls. That means using ? outside of a function call throws a compiler error.

view this post on Zulip Johannes Maas (Mar 10 2024 at 10:14):

Looking at this typed out, the current optional field syntax could be kept to allow destructing the arguments, as in \{ requiredField, optionalField ? 2 }. The main difference is that we would restrict it to only function calls ans throw an error if used elsewhere.

If we also want to allow using optional fields without forcing destructuring, we could keep the above syntax. Alternatives would be \{ argument ? optionalField: 2 } or maybe \{ argument & optionalField ? 2 }, though the latter changes the meaning of the existing syntax undesirably, I think.

view this post on Zulip Johannes Maas (Mar 10 2024 at 10:15):

I guess the main idea is to find a way to deliberately restrict optional fields to function calls. :blush:

view this post on Zulip Kiryl Dziamura (Mar 10 2024 at 10:38):

One of the things I was thinking about is to require passed optional fields to be statically known. So they would be a kind of function extension/specialization and as a result - no optional fields in records are needed. Sounds very similar to your suggestion.

Though not sure about the ergonomics. I guess the point is if the records can’t have optional fields - then there’s no way to pass arbitrary config because each call would require a fixed interface. On the one hand, it can encourage writing more readable explicit function calls. But on the other hand, the dynamic pass would become more verbose and the ergonomics may suffer

view this post on Zulip Johannes Maas (Mar 10 2024 at 10:46):

Do you mean that it would be helpful if the documentation and compiler showed the type as { requiredField : U8, optionalField ? U8 = 2 }?

view this post on Zulip Kiryl Dziamura (Mar 10 2024 at 11:14):

I mean, if records have no optional fields, what would be the type of list of configs, for example?

add = \{ x, y ? 1 } -> x + y
configs = [{x:1, y:2}, {x:1}] # what is the type?
List.map configs add

view this post on Zulip Johannes Maas (Mar 10 2024 at 11:18):

I'd say that wouldn't be allowed because a list must contain elements of the same type and the records are incompatible. (The rule is tjat ? is only allowed in function arguments.)

view this post on Zulip Johannes Maas (Mar 10 2024 at 11:19):

But you could call add with either element explicitly.

view this post on Zulip Kiryl Dziamura (Mar 10 2024 at 11:19):

Exactly. So to overcome it one would have to use tags or fill the records with their own defaults explicitly.

view this post on Zulip Johannes Maas (Mar 10 2024 at 11:20):

Yes. What is your concern with that?

view this post on Zulip Kiryl Dziamura (Mar 10 2024 at 15:56):

E. g. if you once added another config with an extra field to the list - you have to fill other configs with defaults to match the interface.

I use to be on the strict side, but the absence of optional fields in records could bring complicated rituals around them. I’m curious what others think though.

view this post on Zulip Johannes Maas (Mar 10 2024 at 20:02):

Couldn't you refactor the code to provide one record with your common default values and then override the fields in the list?

By the way, I feel that the list of configs is not the usual way of how functions should be called. So I'm concerned that we give too much emphasis on a case that is uncommon and where it is ok to have minor inconveniences such as having to define the defaults yourself. :wink:

view this post on Zulip Kiryl Dziamura (Mar 10 2024 at 21:09):

I feel like the devil’s advocate :grimacing:
It might be not a list but the result of when or ifelse

view this post on Zulip Johannes Maas (Mar 10 2024 at 21:14):

It's interesting!

Could you help me out with another example? I don't see what if or when would change... :sweat_smile:

view this post on Zulip Kiryl Dziamura (Mar 10 2024 at 21:39):

f = \x ->
  cfg = if x > 0 then { x, y: x } else { x } # what’s the type?
  add cfg

I know, a made-up example. The point is, you have to add an explicit value y in the else branch even if you actually want this value to be exactly default. The example might be rewritten this way tho:

f = \x ->
  if x > 0 then
    add { x, y: x }
  else
    add { x }

But in my opinion it’s an ergonomics tradeoff.

Also, what about bypassing the config downstream? If there’s no defaults in records, then internal calls will never apply their defaults as they’re overwritten upstream.

view this post on Zulip Johannes Maas (Mar 10 2024 at 22:30):

I'm still having trouble understanding what you're getting at. The examples you show currently don't compile, they wouldn't either with the idea I propose, and we seem to agree that these examples are edge cases and the main usage is direct function calling. Am I missing something? :blush:

I also didn't understand what you mean with bypassing the config, I think I'd need another example. :sweat_smile:

view this post on Zulip Johannes Maas (Mar 10 2024 at 22:30):

You're examples were really helpful, by the way!

view this post on Zulip Kiryl Dziamura (Mar 11 2024 at 02:13):

Ha. I was sure this should compile, but it was hard to check from mobile:

add = \{ x, y ? 1 } -> x + y
f = \x ->
  cfg : { x : a, y ? a }
  cfg = if x > 0 then { x, y : x } else { x }
  add cfg

(f 1, f -1)

It seems not intuitive to me though. Probably a bug, but I apologize for not working examples.

Anyway, speaking of the bypassing:

internal = \{ x ? 1 } -> x
external = \arg -> internal arg
(external {})

Currently, it returns the expected 1
What is the type of arg with lack of optionals in records? Isn’t it have to be a record with an optional field? Since it’s forbidden, we have to destruct the arg first:

internal = \{ x ? 1 } -> x
external = \{ x ? 2 } -> internal { x }

But this way default field from internal will be overwritten so there’s no way to bypass the absence of x.

My idea was having a syntax like

markdownFmt@{ color ? Str, bold ? Bool } : Str -> Str
markdowbFmt@{ color ? "black", bold ? Bool.false } = \str -> …

# destruct on demand
markdowbFmt@config = \str ->
  { color ? "black", bold ? Bool.false } = config
  …

# direct parametrization
markdownFmt@{ color : "red" } "Apple”

# bypass from parameteizarion
g@config = \str -> f@config str

# bypass from args, so a record can be converted to a param record if their types are the same
g = \str, config -> f@config str

# but this is not possible because the params record can’t be converted to a regular record
g@config -> h config

# indirect parameter pass
f = \str, bold -> markdownFmt@{ bold } str

# indirect config pass
config = { bold : Bool.true }
f@config "Roc"

# fully default
markdownFmt "Roc"

# it's even possible to empathize that defaults are used
markdownFmt@{} "Roc"

# range
List.range@{ step: 3 } (At 0) (Length 100)

Where @{ … } is the function parametrization record (plain of course). This way no need to tie optional fields to a specific argument and enforce destructure. With a special syntax, you always know where defaults are overwritten. With this design optional fields in records are not needed. Extra functionality is decoupled from the mandatory interface.

I like how it feels, but it has almost the same problems with rituals I think (in particular, in the case of a list of configs / branching). The biggest problem is probably the situation when optional parameter is moved to mandatory args: it demands much more refactoring on the call site. And, the special syntax might be weird (or neat? :big_smile:), tho it emphasizes extra powers.

view this post on Zulip Kiryl Dziamura (Mar 11 2024 at 03:22):

Omg, I edited the message a dozen times. It seems I believe in the idea more and more :big_smile:

view this post on Zulip Kiryl Dziamura (Mar 11 2024 at 07:49):

However it’s just a decoupling. You still can pass defaults as a value but it’s more cumbersome with this design

view this post on Zulip Anton (Mar 11 2024 at 09:06):

It seems not intuitive to me though. Probably a bug, but I apologize for not working examples.

I understand the confusion but this is expected behavior, given "Destructuring is the only way to implement a record with optional fields. ".

view this post on Zulip Kiryl Dziamura (Mar 11 2024 at 09:58):

I see. Then my only concern about the proposal is no way to bypass the record with optional fields

view this post on Zulip Johannes Maas (Mar 11 2024 at 10:33):

It seems not intuitive to me though. Probably a bug, but I apologize for not working examples.

No worries, I had the same problem with the current optional fields! Thanks for taking the time to explain, lots of interesting things to unpack.

view this post on Zulip Kiryl Dziamura (Mar 11 2024 at 10:34):

Destructuring is the only way to implement a record with optional fields.

I know the rule, but it never clicked, hence the confusion. I understand why this is not possible:

x : { a ? Str }
x.a

but if the record is protected by destructuring, I can't feel why this is forbidden:

x : { a ? Str } = if cond then {} else { a : "foo" }

view this post on Zulip Johannes Maas (Mar 11 2024 at 10:36):

Passing the config through to an internal function I agree might be something you'd like to do. So there I'm more hesitant about the ritual of redefining the defaults as in the other cases with if and when.

view this post on Zulip Johannes Maas (Mar 11 2024 at 10:37):

Synctactically tying a special config record to the function is interesting. Though as you said, if we only use that for optional fields, moving a required field to be optional is a breaking change which feels undesirable.

view this post on Zulip Kiryl Dziamura (Mar 11 2024 at 11:23):

Another concern regarding the limitation from above:

# library

fmt = \str, { size ? 8, fontFamily ? "sans-serif" } -> ...

# app

paragraph = [
    ("Lorem", { size : 20 }),
    ("ipsum", { fontFamily : "Arial" }),
] |> List.map \(str, config) -> fmt str config

There's no way to preserve the defaults as the library user so you have these options:

  1. add fmt call explicitly to each item in the list (probably the best choice here but I can imagine it won't work in some more complex scenarios)
  2. use tagged unions for your fields permutations
  3. introduce fmtDefaults = { size : 8, fontFamily : "sans-serif" }, extend each item in the list with the fmtDefaults and keep it up with the defaults in the imported module
  4. ask library owner to expose fmtDefaults and extend each item in the list with this record
  5. persuade the library owner to not use defaults (but have tagged unions instead) there because the function is commonly used for the edge case

Yes, it's still a made-up example with a list. My concern is that this rule

The ergonomics of destructuring mean this wouldn't be a good fit for data modeling, consider using a Result type instead.

can be unknown ahead of time because arguments (destructuring) are part of the functionality provider, but the ergonomics problem happens on the consumer side (data modeling)


Last updated: Jun 16 2026 at 16:19 UTC