Skip to content

Commit dddf188

Browse files
joeldrapperclaude
andcommitted
Return Literal::Undefined from collection lookups, never a fabricated nil
Any `Literal::Hash` or `Literal::Array` method that promises an element now returns the element or `Literal::Undefined` — a missing key, an out-of-bounds index, an empty array, or a value that isn't found never fabricates a nil the type doesn't permit. `#[]` accepts a fetch-style fallback block (`hash[key] { fallback }`) that fires only on the undefined case; bare `fetch` still raises, so the two spellings stay distinct. Backing hash defaults are no longer consulted, since a `default_proc` could leak values the types never checked. `Literal::Tuple#[]` instead raises `IndexError` out of bounds, mirroring `#[]=`: a tuple's length is part of its type, so there is no missing case. Range lookups, which leaked an untyped `Array`, now raise too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0a333b9 commit dddf188

6 files changed

Lines changed: 173 additions & 39 deletions

File tree

lib/literal/array.rb

Lines changed: 70 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@
1212
#
1313
# The element type is fixed at construction and never changes in place — it can
1414
# neither be widened nor narrowed. `narrow` and `widen` return new instances.
15+
#
16+
# Methods that promise an element return `Literal::Undefined` when there is no
17+
# element to return — an out-of-bounds index, an empty array, a value that
18+
# isn't found — never `nil`. `nil` only ever appears when the element type
19+
# actually permits it. For a per-lookup fallback, pass a block —
20+
# `array[index] { fallback }` — or use `fetch` to raise on a missing index.
1521
class Literal::Array
1622
class Generic
1723
include Literal::Type
@@ -142,19 +148,24 @@ def ==(other)
142148
Literal::Array === other && @__value__ == other.__value__
143149
end
144150

145-
def [](index, length = nil)
146-
if length
147-
slice = @__value__[index, length]
148-
slice && __with__(slice)
151+
# Returns the element or slice. When there is nothing at the index — out of
152+
# bounds, or a slice starting beyond the end — yields the index to the block
153+
# if one is given, and returns `Literal::Undefined` otherwise.
154+
def [](index, length = nil, &fallback)
155+
slice = if length
156+
@__value__[index, length]
157+
elsif Range === index
158+
@__value__[index]
149159
else
150-
case index
151-
when Range
152-
slice = @__value__[index]
153-
slice && __with__(slice)
154-
else
155-
@__value__[index]
156-
end
160+
return @__value__.fetch(index, &fallback) if fallback
161+
162+
return @__value__.fetch(index) { Literal::Undefined }
157163
end
164+
165+
return __with__(slice) if slice
166+
return yield(index) if fallback
167+
168+
Literal::Undefined
158169
end
159170

160171
def []=(index, value)
@@ -206,16 +217,22 @@ def count(...)
206217
@__value__.count(...)
207218
end
208219

209-
def delete(...)
210-
@__value__.delete(...)
220+
def delete(value, &block)
221+
return @__value__.delete(value, &block) if block
222+
223+
@__value__.delete(value) { Literal::Undefined }
211224
end
212225

213-
def delete_at(...)
214-
@__value__.delete_at(...)
226+
def delete_at(index)
227+
@__value__.fetch(index) { return Literal::Undefined }
228+
229+
@__value__.delete_at(index)
215230
end
216231

217-
def dig(...)
218-
@__value__.dig(...)
232+
def dig(index, *indexes)
233+
@__value__.fetch(index) { return Literal::Undefined }
234+
235+
@__value__.dig(index, *indexes)
219236
end
220237

221238
def drop(n)
@@ -254,7 +271,10 @@ def filter_map(type, &block)
254271
end
255272

256273
def first(n = nil)
257-
n ? __with__(@__value__.first(n)) : @__value__.first
274+
return __with__(@__value__.first(n)) if n
275+
return Literal::Undefined if @__value__.empty?
276+
277+
@__value__.first
258278
end
259279

260280
def flat_map(type, &block)
@@ -319,7 +339,10 @@ def join(...)
319339
end
320340

321341
def last(n = nil)
322-
n ? __with__(@__value__.last(n)) : @__value__.last
342+
return __with__(@__value__.last(n)) if n
343+
return Literal::Undefined if @__value__.empty?
344+
345+
@__value__.last
323346
end
324347

325348
def map(type, &block)
@@ -354,23 +377,35 @@ def map!(&block)
354377
end
355378

356379
def max(n = nil, &)
357-
n ? __with__(@__value__.max(n, &)) : @__value__.max(&)
380+
return __with__(@__value__.max(n, &)) if n
381+
return Literal::Undefined if @__value__.empty?
382+
383+
@__value__.max(&)
358384
end
359385

360386
def max_by(n = nil, &block)
361387
raise ArgumentError.new("#max_by requires a block.") unless block
362388

363-
n ? __with__(@__value__.max_by(n, &block)) : @__value__.max_by(&block)
389+
return __with__(@__value__.max_by(n, &block)) if n
390+
return Literal::Undefined if @__value__.empty?
391+
392+
@__value__.max_by(&block)
364393
end
365394

366395
def min(n = nil, &)
367-
n ? __with__(@__value__.min(n, &)) : @__value__.min(&)
396+
return __with__(@__value__.min(n, &)) if n
397+
return Literal::Undefined if @__value__.empty?
398+
399+
@__value__.min(&)
368400
end
369401

370402
def min_by(n = nil, &block)
371403
raise ArgumentError.new("#min_by requires a block.") unless block
372404

373-
n ? __with__(@__value__.min_by(n, &block)) : @__value__.min_by(&block)
405+
return __with__(@__value__.min_by(n, &block)) if n
406+
return Literal::Undefined if @__value__.empty?
407+
408+
@__value__.min_by(&block)
374409
end
375410

376411
def minmax(&)
@@ -431,7 +466,10 @@ def partition(&block)
431466
end
432467

433468
def pop(n = nil)
434-
n ? __with__(@__value__.pop(n)) : @__value__.pop
469+
return __with__(@__value__.pop(n)) if n
470+
return Literal::Undefined if @__value__.empty?
471+
472+
@__value__.pop
435473
end
436474

437475
# Returns a Literal::Array of Literal::Tuples with every combination of our
@@ -509,7 +547,10 @@ def rotate!(count = 1)
509547
end
510548

511549
def sample(n = nil, random: Random)
512-
n ? __with__(@__value__.sample(n, random:)) : @__value__.sample(random:)
550+
return __with__(@__value__.sample(n, random:)) if n
551+
return Literal::Undefined if @__value__.empty?
552+
553+
@__value__.sample(random:)
513554
end
514555

515556
def select(&block)
@@ -526,7 +567,10 @@ def select!(&block)
526567
end
527568

528569
def shift(n = nil)
529-
n ? __with__(@__value__.shift(n)) : @__value__.shift
570+
return __with__(@__value__.shift(n)) if n
571+
return Literal::Undefined if @__value__.empty?
572+
573+
@__value__.shift
530574
end
531575

532576
def shuffle(random: Random)

lib/literal/hash.rb

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313
# The key and value types are fixed at construction and never change in place —
1414
# they can neither be widened nor narrowed. `narrow` and `widen` return new
1515
# instances.
16+
#
17+
# Looking up a missing key returns `Literal::Undefined`, never `nil` — `nil` is
18+
# only ever a value the value type actually permits. For the same reason, a
19+
# `Literal::Hash` has no default value machinery: a default could fabricate
20+
# values the types never checked. For a per-lookup fallback, pass a block —
21+
# `hash[key] { fallback }` — or use `fetch` to raise on a missing key.
1622
class Literal::Hash
1723
class Generic
1824
include Literal::Type
@@ -110,8 +116,12 @@ def ==(other)
110116
Literal::Hash === other && @__value__ == other.__value__
111117
end
112118

113-
def [](key)
114-
@__value__[key]
119+
# Returns the value for the key. For a missing key, yields the key to the
120+
# block if one is given, and returns `Literal::Undefined` otherwise.
121+
def [](key, &fallback)
122+
return @__value__.fetch(key, &fallback) if fallback
123+
124+
@__value__.fetch(key) { Literal::Undefined }
115125
end
116126

117127
def []=(key, value)
@@ -157,12 +167,16 @@ def compact!
157167
self
158168
end
159169

160-
def delete(...)
161-
@__value__.delete(...)
170+
def delete(key, &block)
171+
return @__value__.delete(key, &block) if block
172+
173+
@__value__.delete(key) { Literal::Undefined }
162174
end
163175

164-
def dig(...)
165-
@__value__.dig(...)
176+
def dig(key, *keys)
177+
return Literal::Undefined unless @__value__.key?(key)
178+
179+
@__value__.dig(key, *keys)
166180
end
167181

168182
def each(&block)

lib/literal/tuple.rb

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,22 @@ def ==(other)
137137
Literal::Tuple === other && @__value__ == other.__value__
138138
end
139139

140+
# Every valid tuple index always holds a value, so lookups never need a
141+
# "missing" representation — an index outside the tuple is a programmer
142+
# error and raises, exactly like `#[]=`.
140143
def [](index)
141-
@__value__[index]
144+
size = @__value__.size
145+
normalized = index
146+
147+
if (Integer === normalized) && (normalized < 0)
148+
normalized += size
149+
end
150+
151+
unless Integer === normalized && normalized >= 0 && normalized < size
152+
raise IndexError.new("Index #{index.inspect} is out of bounds for a tuple of size #{size}.")
153+
end
154+
155+
@__value__[normalized]
142156
end
143157

144158
def []=(index, value)

test/array.test.rb

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,21 +219,26 @@ def refute_shared_storage(a, b)
219219

220220
assert_equal array[0], 1
221221
assert_equal array[-1], 3
222-
assert_equal array[3], nil
222+
assert_equal array[3], Literal::Undefined
223+
assert_equal array[3] { |index| index }, 3
224+
assert_equal array[0] { |index| index }, 1
223225
end
224226

225227
test "#[] with a range returns a typed sub-array" do
226228
array = Literal::Array(Integer).new(1, 2, 3)
227229

228230
assert_literal_array array[0..1], type: Integer, values: [1, 2]
229231
assert_literal_array array[2..], type: Integer, values: [3]
230-
assert_equal array[5..], nil
232+
assert_equal array[5..], Literal::Undefined
233+
assert_equal array[5..] { :fallback }, :fallback
231234
end
232235

233236
test "#[] with a start and length returns a typed sub-array" do
234237
array = Literal::Array(Integer).new(1, 2, 3)
235238

236239
assert_literal_array array[1, 2], type: Integer, values: [2, 3]
240+
assert_equal array[5, 2], Literal::Undefined
241+
assert_equal array[5, 2] { :fallback }, :fallback
237242
end
238243

239244
test "#fetch" do
@@ -250,7 +255,8 @@ def refute_shared_storage(a, b)
250255

251256
assert_equal array.first, 1
252257
assert_equal array.last, 3
253-
assert_equal Literal::Array(Integer).new.first, nil
258+
assert_equal Literal::Array(Integer).new.first, Literal::Undefined
259+
assert_equal Literal::Array(Integer).new.last, Literal::Undefined
254260
end
255261

256262
test "#first and #last return typed arrays with an argument" do
@@ -827,6 +833,28 @@ def refute_shared_storage(a, b)
827833
assert_equal array.to_a, [3]
828834
end
829835

836+
test "element lookups return Literal::Undefined when there is no element, never nil" do
837+
empty = Literal::Array(Integer).new
838+
nilable = Literal::Array(_Nilable(Integer)).new(nil)
839+
840+
assert_equal empty.pop, Literal::Undefined
841+
assert_equal empty.shift, Literal::Undefined
842+
assert_equal empty.sample, Literal::Undefined
843+
assert_equal empty.min, Literal::Undefined
844+
assert_equal empty.max, Literal::Undefined
845+
assert_equal empty.min_by(&:itself), Literal::Undefined
846+
assert_equal empty.max_by(&:itself), Literal::Undefined
847+
assert_equal empty.delete(1), Literal::Undefined
848+
assert_equal empty.delete(1) { |element| element }, 1
849+
assert_equal empty.delete_at(0), Literal::Undefined
850+
assert_equal empty.dig(0, 0), Literal::Undefined
851+
852+
# A nil element is a real element, so it is returned as itself.
853+
assert_equal nilable.first, nil
854+
assert_equal nilable[0], nil
855+
assert_equal nilable.pop, nil
856+
end
857+
830858
test "#pop and #shift return elements without an argument" do
831859
array = Literal::Array(Integer).new(1, 2, 3)
832860

test/hash.test.rb

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ def assert_literal_hash(hash, key_type:, value_type:, entries:)
128128
hash = Literal::Hash(Symbol, Integer).new({ a: 1, b: 2 })
129129

130130
assert_equal hash[:a], 1
131-
assert_equal hash[:c], nil
131+
assert_equal hash[:c], Literal::Undefined
132132
assert_equal hash.fetch(:b), 2
133133
assert_equal hash.fetch(:c, 3), 3
134134
assert_raises(KeyError) { hash.fetch(:c) }
@@ -143,6 +143,31 @@ def assert_literal_hash(hash, key_type:, value_type:, entries:)
143143
assert Literal::Hash(Symbol, Integer).new.empty?
144144
end
145145

146+
test "#[] yields the key to a given block when it is missing" do
147+
hash = Literal::Hash(Symbol, Integer).new({ a: 1 })
148+
149+
assert_equal hash[:a] { |key| key }, 1
150+
assert_equal hash[:b] { |key| key }, :b
151+
assert_equal hash[:b] { 0 }, 0
152+
end
153+
154+
test "looking up a missing key returns Literal::Undefined, distinct from a nil value" do
155+
hash = Literal::Hash(Symbol, _Nilable(Integer)).new({ a: nil })
156+
157+
assert_equal hash[:a], nil
158+
assert_equal hash[:b], Literal::Undefined
159+
assert_equal hash.dig(:b, :deeper), Literal::Undefined
160+
end
161+
162+
test "a backing hash's default value never leaks through lookups" do
163+
# The default was never type checked, so honoring it would break the
164+
# guarantee that lookups only return values the value type permits.
165+
hash = Literal::Hash(Symbol, Integer).new(Hash.new("unchecked").merge(a: 1))
166+
167+
assert_equal hash[:b], Literal::Undefined
168+
assert_equal hash.dig(:b, :deeper), Literal::Undefined
169+
end
170+
146171
test "#each yields pairs and returns self" do
147172
hash = Literal::Hash(Symbol, Integer).new({ a: 1, b: 2 })
148173
yielded = []
@@ -317,10 +342,17 @@ def assert_literal_hash(hash, key_type:, value_type:, entries:)
317342
hash = Literal::Hash(Symbol, Integer).new({ a: 1 })
318343

319344
assert_equal hash.delete(:a), 1
320-
assert_equal hash.delete(:a), nil
345+
assert_equal hash.delete(:a), Literal::Undefined
321346
assert hash.empty?
322347
end
323348

349+
test "#delete yields the key to a given block when it is missing" do
350+
hash = Literal::Hash(Symbol, Integer).new({ a: 1 })
351+
352+
assert_equal hash.delete(:b) { |key| key }, :b
353+
assert_equal hash.delete(:a) { |key| key }, 1
354+
end
355+
324356
test "#select!, #reject!, #compact! and #clear return self" do
325357
hash = Literal::Hash(Symbol, _Nilable(Integer)).new({ a: 1, b: 2, c: nil })
326358

test/tuple.test.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,9 @@ def assert_type_equal(actual, expected)
139139

140140
assert_equal tuple[0], "a"
141141
assert_equal tuple[-1], 1
142-
assert_equal tuple[2], nil
142+
assert_raises(IndexError) { tuple[2] }
143+
assert_raises(IndexError) { tuple[-3] }
144+
assert_raises(IndexError) { tuple[0..1] }
143145
assert_equal tuple.fetch(1), 1
144146
assert_raises(IndexError) { tuple.fetch(2) }
145147
assert_equal tuple.first, "a"

0 commit comments

Comments
 (0)