Feature #22108
closedComputed hash keys with (expr): syntax
Description
Warning
This has potentially been obsoleted by Feature #22111
Computed hash keys with (expr): syntax¶
Allow { (expr): value } as a computed hash key.
Almost 20 years in the making, the missing puzzle piece for Hash's "new" colon notation:
h = {
name: "symbol shorthand", # Ruby 1.9+
"quoted label": "symbol label", # Ruby 2.2+ (Feature #4935)
value_omission: , # Ruby 3.1+ (Feature #14579)
(expr): "computed", # THIS PROPOSAL
}
Motivation¶
Ruby has several colon-based hash key syntaxes but the => ("hash rocket") is still needed for non-symbolic keys.
Adding (expr): is a baby step toward allowing all-colon hashes:
## Before -- mixed styles
n = 42; { key1: "symbol", "key-#{2}": "quoted symbol", RUBY_VERSION:, n => "bar" }.keys
# => [:key1, :"key-2", :RUBY_VERSION, 42]
## After -- uniform colon syntax
n = 42; { key1: "symbol", "key-#{2}": "quoted symbol", RUBY_VERSION:, n : "bar" }.keys
# => [:key1, :"key-2", :RUBY_VERSION, 42]
(And maybe a step closer to one day retiring our old friend "hash rocket" from Hashes entirely?)
Completing the colon family¶
| Example | Ruby | Feature | Key type |
|---|---|---|---|
{ name: value } |
v1.9 | Symbol | |
{ "quoted label": value } |
v2.2 | Feature #4935 | Symbol (quoted label) |
{ value_omission: } |
v3.1 | Feature #14579 | Value omission |
{ (expr): value } |
??? | Feature #22108 | Computed |
Readability for computed-key-heavy code¶
Code that builds dynamic hashes currently forces a style break midway through a literal.
This is especially noticeable with interpolated keys, numeric keys, or variable-driven keys.
Reducing => overloading¶
The => token is now being used to serve more purposes than just the Hash literal (aka "Hash rocket") it was originally used for.
- rightward assignment
- pattern capture
- rescue variables
Reducing its use in Hashes simplifies the language, especially for newcomers.
Design¶
(expr): vs [expr]:¶
Parenthesized expressions were chosen over square bracket delimited:
- Idempotent wrapping:
((x))=(x), but[[x]]!=[x](nested array) - Array keys:
{(["a"]): "b"}reads naturally vs{[["a"]]: "b"}requiring extra wrapping jqprecedent: the JSON query language uses identical{("a"+"b"): 59}syntax (since jq 1.6, 2018)- Parentheses signals a grouping or "evaluate this", whereas square brackets signals an Array/container
Improving on JavaScript¶
JavaScript's "computed property names" { [expr]: value } were introduced in ECMAScript 2015 (ES6), 3 years before the jq version.
However in Ruby, [] already means Array literal and method call.
Rather than overloading [] further, (expr): (from jq) improves on the JavaScript design.
Parentheses naturally signal evaluation, (): inside {} is unambiguous to the lexer, and it matches the well-established jq convention.
Edge cases handled¶
(expr) : value(space before colon) -- syntax error (consistent withname : value){ (expr): }(value omission) -- syntax error (runtime expression, no compile-time local to infer)- Mixed styles:
{ (1): "one", two: "two", "three": "three" }-- valid - Nested parens:
{ ((1 + 2) * 3): "nested" }-- valid - Nested hashes:
{ ({(1): "one"}): "two" }-- valid
Implementation¶
Three changes to parse.y (Lrama LALR(1) parser, zero new conflicts):
- Lexer: emit
tLABEL_ENDwhenEXPR_ENDFNstate, no space before:, and insidebrace_nest > 0 - Grammar: new
assocalternative --tLPAREN compstmt ")" tLABEL_END arg_value - Precedence:
%nonassoc ')'to disambiguate
Reference implementation: feature/computed-hash-keys PR on GitHub.
Historical context¶
A version of this was discussed on ruby-core in October 2007 (as part of "General hash keys for colon notation", murphy).
Unfortunately it was brought up during the v1.9 feature freeze, but it looks like Matz's invitation to discuss for v2.0 didn't end up going anywhere.
Since then, "quoted label": (Ruby 2.2, Feature #4935) and value omission (Ruby 3.1, Feature #14579) have expanded the colon family, making the computed-key gap more conspicuous.
Meanwhile, jq introduced the identical {("a"+"b"):59} syntax in jq 1.6 (2018), demonstrating real-world viability.
Open questions¶
- Is this syntax acceptable to the community?
- Is the jq precedent compelling enough to address the "no language does this" objection?
Future directions¶
All colon-based key syntaxes would now be available.
This opens up the possibility of eventually deprecating => from Hash literals (while keeping it for rescue, pattern matching, and rightward assignment).
This proposal does not require that change - it is simply the enabling step, and any deprecation timeline could be a separate discussion.
Updated by yertto (_ yertto) about 1 month ago
- Description updated (diff)
Updated by yertto (_ yertto) about 1 month ago
- Description updated (diff)
Updated by yertto (_ yertto) about 1 month ago
- Description updated (diff)
Updated by nobu (Nobuyoshi Nakada) about 1 month ago
Is it same as "#{expr}": value?
P.S. pattern capture and rightward assignment are the same thing.
Updated by yertto (_ yertto) about 1 month ago
· Edited
No, its different from { "#{expr}": value }.
That would produce a Symbol for the key.
eg.
Whereas using (): the key stays unchanged (ie. as a String in this case),
ie. an alternative to using =>:
Updated by yertto (_ yertto) about 1 month ago
- Description updated (diff)
Updated by yertto (_ yertto) about 1 month ago
- Description updated (diff)
Updated by yertto (_ yertto) about 1 month ago
- Description updated (diff)
Updated by yertto (_ yertto) about 1 month ago
- Description updated (diff)
Updated by yertto (_ yertto) about 1 month ago
- Description updated (diff)
Updated by yertto (_ yertto) about 1 month ago
- Description updated (diff)
Updated by yertto (_ yertto) about 1 month ago
- Description updated (diff)
Updated by yertto (_ yertto) about 1 month ago
- Description updated (diff)
Updated by yertto (_ yertto) about 1 month ago
- Description updated (diff)
Updated by yertto (_ yertto) about 1 month ago
- Description updated (diff)
Updated by yertto (_ yertto) about 1 month ago
- Description updated (diff)
Updated by kddnewton (Kevin Newton) about 1 month ago
I am not very in favor of adding yet another hash assoc syntax, especially not by overloading the meaning of the colon. Right now a colon suffix always means a symbol (indeed I've seen static analysis tools that check slice[-1] == ":" to determine if it's a symbol). It's not an operator like this commit attempts to treat it.
Realistically, I don't see hash rockets ever going away (indeed some people have their linters enforce usage in all places because they prefer the consistency), so I don't think that argument has any real weight to it. With that in mind, I think the goal of simplifies the language, especially for newcomers is very much not met, because there is already a way to do this and this just bloats the grammar even further.
Updated by yertto (_ yertto) about 1 month ago
· Edited
yet another hash assoc syntax
You're right — : is not an operator. And this change doesn't treat it as one.
: works the same way it always has: you put something on the left, put : after it, and that something becomes the hash key.
The change isn't about adding an operator, it's about completing an existing syntax family that currently stops at two left-hand forms.
Ruby already has two hash key syntax families: => (hash rocket) and : (hash colon).
The proposal I'm making here is whether the hash colon family should be complete or remain half-implemented.
Today, the hash colon covers most key forms but has a gap:
| Key form | Rocket style | Colon style |
|---|---|---|
| Static symbol | :foo => value |
foo: value |
| Quoted symbol | :"foo" => value |
"foo": value |
| Value omission | N/A | name: |
| Computed key | expr => value |
❌ (expr): value (proposed) |
The incomplete table forces every computed key into rocket syntax, which is the real asymmetry in modern Ruby.
Adding (expr): doesn't increase bloat, it fills the one hole that prevents the hash colon family from being a complete, consistent syntax.
colon suffix always means a symbol
You're right that foo: and "foo": both produce symbols. But the colon isn't what makes them symbols, the grammar production is.
Ruby has three separate grammar rules for the left side of : in a hash:
| Left side | What happens | Key type |
|---|---|---|
foo |
Bareword interpreted as a symbol | Symbol :foo |
"foo" |
String wrapped into a symbol | Symbol :"foo" |
(expr) |
Expression evaluated, result used directly (new) | result of expr |
The colon does the same thing in all three: it marks the key boundary.
What happens to the left-hand value depends on which rule matched, not on anything the colon does.
"foo": proves this. The colon doesn't turn "foo" into a symbol; the rule for strings wraps it into one.
(expr): simply omits the wrapping step; the expression result is used as-is, which is the most honest interpretation.
So the claim "colon suffix always means symbol" is true as its currently observed but still questionable at the mechanism level.
(expr): doesn't break the rule, it reveals to us that the rule was about the production, not the colon.
(I've seen static analysis tools that check slice[-1] == ":" to determine if it's a symbol.)
(This side note seems like a distraction. The debate about what : means is better had on linguistic grounds than by how heuristics happen to work today. I'd prefer to focus on what the syntax should mean, not on what existing tools happen to do.)
there is already a way to do this (
=>already works)
"Already works" sets the bar at syntax correctness, ignoring ergonomics, readability, and consistency.
=> does work, but the question is whether Ruby should continue to force such a stark style break at non-symbol keys.
Every tutorial teaches label: syntax as the default for symbol keys. Style guides recommend it. The community shifted preference decades ago.
Telling users "use rockets for anything that isn't a bare symbol" is the actual inconsistency, not the addition of one missing form.
the goal of "simplifies the language, especially for newcomers" is very much not met
Ruby has baggage: two separators, and : has an association with Symbol coercion, but things don't have to stay that way.
There's a path forward...
- Add
(expr):a form where:acts purely as a key boundary, with zero coercion. Establishes the precedent that:doesn't inherently mean Symbol. - Shift the mental model. Clarify the colon's role. Currently
:does double duty - it marks the key boundary and coerces bare words and Strings to Symbols.(expr):cleanly separates these: forfoo:and"foo":, the left-hand form still triggers Symbol coercion; for(expr):, it doesn't. The colon itself is just a key boundary marker in all three cases and the coercion is a property of the left-hand form, not the colon. - The door opens for future cleanup.
(expr): is uniquely valuable in this trajectory because it's the least committal form.
It doesn't paint us into any corner.
It's compatible with Symbol keys, String keys, or any future type decision.
I guess for the newcomer it comes down to which would be simpler.
A. Without (expr):
"So : makes a symbol key: { name: "Alice" }. That's the modern way. But if you want a computed key, you have to switch to =>: { expr => value }. Different rules, different separator. You'll get used to it, or you could just use the rocket everywhere. No wait ... then you miss out on the value_omission: case, so that also needs to use the colon."
B. With (expr):
": after any expression means what comes before it is the key. If it's a bare word, it gets converted to a symbol. If it's a string, same thing, it converts to a symbol. If it's an expression, the result is used directly as the key. One consistent rule: : means key boundary."
IMO, the latter would be simpler to teach, simpler to remember, and reduces context switches.
The implementation cost (a handful of lines in the parser) seems trivial compared to the gain in clarity.
(BTW, I had considered making whitespace the disambiguator. ie. { foo: bar } for symbol keys, { foo : bar } for computed keys, but I came up with a few reasons against it, and so went with the parentheses design instead. Someone else is welcome to argue for that case separately, but I suspect there be too many dragons introducing space sensitivity to Ruby.)
I think the real debate that you are raising here is whether or not the => (hash rocket) is here to stay forever, or could one day be deprecated.
My gut feeling is that (expr): is a step toward reducing => usage.
While your gut feeling is that => will never go away and so (expr): is just more syntax for the same thing.
Is that a fair assessment?
While neither of us can prove our gut feelings will eventuate.
One thing is certain: without some kind of addition like (expr):, deprecation of Hash's rocket is structurally impossible.
Every computed key today requires =>, guaranteeing rocket usage in perpetuity.
(expr): is a minimum viable step to find out whether rockets can fade.
It costs a handful of lines in the grammar.
And if => usage doesn't decline, nothing is lost.
But if we never try, we'll never know.
And for those folks who currently enforce rockets everywhere via their linters, this change means they now have the option to shift toward the hash colon instead (if they want to).
That's not the language forcing change; it's the language enabling choice.
Rejecting an addition like (expr): isn't pragmatism, it's guaranteeing rockets within Hashes will stay forever.
Updated by yertto (_ yertto) about 1 month ago
- Description updated (diff)
Updated by yertto (_ yertto) about 1 month ago
· Edited
(BTW, I had considered making whitespace the disambiguator. ie. { foo: bar } for symbol keys, { foo : bar } for computed keys, but I came up with a few reasons against it, and so went with the parentheses design instead. Someone else is welcome to argue for that case separately, but I suspect there be too many dragons introducing space sensitivity to Ruby.)
Turns out the implementation didn't have the dragons I was expecting.
The only space sensitivity it introduces is for the existing symbol-label syntax.
And somehow computed keys need no disambiguation at all. ¯\(ツ)/¯
This simple addition was all that was needed for the parser.
Just wondering if someone else is able to throw some horrific show-stopping edge case at it and break it?
Otherwise, perhaps we should close this issue and discuss the proposal in #22111 instead?
Updated by yertto (_ yertto) about 1 month ago
- Description updated (diff)
Updated by yertto (_ yertto) about 1 month ago
- Description updated (diff)
Updated by matz (Yukihiro Matsumoto) 17 days ago
- Status changed from Open to Rejected
I am against this proposal.
The value of the label syntax (key:) is its simple reading: a colon-terminated key is always a Symbol. (expr): breaks this invariant, so readers would have to check for parentheses to know the type of the key. That is a real cost in readability, for little gain, since expr => value already works, is short, and is unambiguous.
Also, I have no intention of deprecating => in hash literals. It is not a legacy syntax to be retired; it is the general form, and the colon syntax is a shorthand for the common Symbol case.
Matz.
Updated by yertto (_ yertto) 15 days ago
The label syntax (key:) may have value, and...
matz (Yukihiro Matsumoto) wrote in #note-23:
expr => valuealready works, is short, and is unambiguous.
However - and perhaps this is best discussed in a separate proposal? - but that's only for Hash Literals.
We're locked out of using non-symbolic keys in Hash Patterns, because the => being commonly used for rightward assignment within a Hash Pattern, can't also be used as key/value separator within the same Hash Pattern.
( With @ktsj (Kazuki Tsujimoto) having to acknowledge this particular shortcoming as "Future Work" in https://speakerdeck.com/k_tsj/pattern-matching-new-feature-in-ruby-2-dot-7?slide=58 )
So my question now is...
Would you consider a proposal to use the "Computed Hash Key" aka "Hash Capsule" (...): syntax introduced in this proposal, for facilitating the use of non-symbolic keys within Hash Patterns?
Updated by kddnewton (Kevin Newton) 12 days ago
We're not "locked out" of using non-symbolic keys in hash patterns because of a syntax restriction, it's because the contract for deconstruct_keys assumes a symbol-keyed hash. It's not just changing the call site, it's also changing the interface.
Also, if this was actually a desired feature with a real use case, => could already be used. We don't need to introduce new syntax.
Updated by yertto (_ yertto) 11 days ago
kddnewton (Kevin Newton) wrote in #note-25:
We're not "locked out" of using non-symbolic keys in hash patterns because of a syntax restriction...
Also, if this was actually a desired feature with a real use case,=>could already be used. We don't need to introduce new syntax.
Whether or not using non-symbolic keys in Hash Patterns is a "desired feature" could be up for debate (and I'm thinking I should raise a separate proposal for that, along with a better example, and have the discussion there instead).
However, I'd like to clearly highlight the syntactic limitations we currently face, and more clearly dispel any misguided belief that using => as a key/value separator can already be used in Hash Patterns, while its still being discussed in this proposal.
Yes the => symbol can already be used in Hash Patterns but ONLY for its role in value capturing (rightward assignment), not for key/value separation.
eg. for this Hash Pattern.
The key (:k) can ONLY be matched against a symbolic key :k, because Hash Patterns can ONLY be created using symbolic keys.
And in this Hash Pattern the value is matched against a String that is then captured into the variable v.
I think the reason why so many of us have confused the role of the => is because Hash Patterns look so deceptively like Hash Literals.
(Perhaps with an added assumption that matching against Hash Patterns provided a superset of what could be matched against using Hash Literals.)
(And to be honest, before raising this, I didn't even know the words "Hash Literal" and "Hash Pattern" existed to be able to clearly distinguish between the two.)
Nb. in that example above (in fact in ALL existing Hash Patterns) the key/value separator is effectively (and exclusively) the : and NEVER the =>.
Which leaves the String => v value pattern side of the key/value pattern pair to get used when matching the value.
(It would be completely wrong to assume that the => is being used as a key/value separator here. ie. where a misguided reader might assume that the key part of the pattern is k: String, and possibly might even go on to incorrectly assume that the key k is of type String.)
Unlike the Hash Literal, there is currently NO way to use eg. the Integer 200 as a key by using the => as a key/value separator in a Hash Pattern.
So currently Ruby could match Hash Literals against Hash Patterns by very clumsily converting ALL the keys to symbolic keys like this:
{ :k => "ok" } in { k: String => v } ; puts "#{v}"
{ :"200" => "ok" } in { "200": String => v } ; puts "200: #{v}"
{ :"200" => "ok" } in { "200": String } | { "201": String } | { "202": String } ; puts "2xx SUCCESS - <NB. variable capture does not even work here>"
Which produces:
(Noting that I'm choosing to keep my Hash Literals as uniform as possible here by exclusively using the => key/value separator - eg. {:k => "ok"} instead of {k: "ok"}, so we can more easily focus in on what's happening in the Hash Patterns they're being matched against.)
However, a new Hash Capsule (...): proposal facilitates the use of : as they key/value separator for ALL key types.
Which means that code using any typed key - both in the Hash Literal and in the Hash Pattern it's being matched against - can now work.
eg.
{ :k => "ok" } in { k: String => v } ; puts " #{v} (where :k === :k)"
{ 200 => "ok" } in { (200 => key): String => v } ; puts " #{key}: #{v} (where 200 === #{key})"
{ 200 => "ok" } in { (200..299 => key): String => v } ; puts "2xx SUCCESS - #{key}: #{v} (where 200..299 === #{key})"
Which produces:
A big advantage of this proposal is that the Hash Literal keys no longer have to be clumsily converted to symbols just to perform the pattern match.
And this provides further advantages when working with those keys because when they remain in their original form because a key like 200 can then be:
- matched against patterns like
200..299 === 200 - used in arithmetic or sorting (which couldn't be done had it been mangled into a symbolic form like
:"200")
I think there is confusion here because the Rightward Assignment (=>) was employed to work within the relatively new Hash Pattern,
at the expense of her much older, but identical sister the Hash Rocket (=>), who had been working as a key/value separator in Hash Literals for decades.
So what the Hash Pattern discussion ultimately boils down to is Ruby's (current) inability to put both sisters to work (ie. using non-symbolic keys AND rightward assignment/value capturing) at the same time when doing the job of pattern matching.
Updated by kddnewton (Kevin Newton) 11 days ago
To be clear, I'm saying if this feature were accepted, you would be able to do a = 1; b = 2; foo in { a => b => c }, which would pattern match { 1 => 2 } and capture the value into c.
Beyond being not possible right now, what is your actual use case?
Also, instead of using an LLM trained on outdated stuff like rightward assignment, would it be possible to have your next response not be AI-generated?
Updated by yertto (_ yertto) 10 days ago
· Edited
Thanks for bearing with me on this one @kddnewton (Kevin Newton),
I believe the most appreciated use cases would be for exact-matching JSON String keys against Hash Pattern String keys without the need to recursively symbolize all the keys in the JSON payload first.
However, by allowing ANY object that responds to === to sit inside the (...): key slot of a Hash Pattern, Ruby could gain fuzzy-matching and capturing not just against the values in a Hash Pattern, but also against it's keys.
(So instead of a Symbol having to exactly match a Symbol, a Range could match Integers, or a Regexp could match Strings.)
Code using the OmniAuth gem has to process nested data in all sorts of different shapes, and pattern matching would be a great fit for that.
So here's an actual use case using the (...): syntax:
EMAIL_KEY_REGEX = /email|e-mail/i
EMAIL_VALUE_REGEX = /^.*@example.com$/
NAME_KEY_REGEX = /name/i
[
{"provider" => "github", "uid" => "123", "info" => {"email" => "octocat@example.com", "name" => "Octo Cat"}},
{"provider" => "saml", "uid" => "emp_001", "info" => {"E-MailAddress" => "emp_001@corporation.com", "Display Name" => "Jane Smith"}},
{"provider" => "saml", "uid" => "emp_002", "info" => {"E-MailAddress" => "corp_user@example.com", "Display Name" => "Example User"}},
{"provider" => "apple", "uid" => "10001", "info" => {"email" => "john.doe@apple.com", "image" => "https://avatars.apple.com/u/10001"}, "extra" => {"raw_info" => {"user" => {"name" => "John Doe"}}}}
].map { |payload|
case payload
in {("info"): {(EMAIL_KEY_REGEX => email_key): EMAIL_VALUE_REGEX => email}}
puts "WARNING: Skipping payload containing @example.com email (key=#{email_key.inspect}, value=#{email.inspect})"
in {("info"): {(EMAIL_KEY_REGEX): email, (NAME_KEY_REGEX): name}}
{email:, name:}
in {("info"): {(EMAIL_KEY_REGEX): email, ("image"): image}, ("extra"): {("raw_info"): {("user"): {(NAME_KEY_REGEX): name}}}}
{email:, name:, image:}
end
}.compact.map { |user_data|
puts "User.create(#{user_data.inspect}) ..."
}
WARNING: Skipping payload containing @example.com email (key="email", value="octocat@example.com")
WARNING: Skipping payload containing @example.com email (key="E-MailAddress", value="corp_user@example.com")
User.create({email: "emp_001@corporation.com", name: "Jane Smith"}) ...
User.create({email: "john.doe@apple.com", name: "John Doe", image: "https://avatars.apple.com/u/10001"}) ...