tuple-ts 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,26 +1,51 @@
1
1
  # tuple-ts
2
2
 
3
- **Immutable interned tuples and named tuples for JavaScript and TypeScript.**
3
+ **TypeScript-first tuples and structs with value-based identity.**
4
4
 
5
- JavaScript normally compares arrays by reference:
5
+ JavaScript normally compares arrays by reference:
6
6
 
7
7
  ```ts
8
- [1, 2] === [1, 2]; // false
8
+ [1, "foo"] === [1, "foo"]; // false!
9
9
  ```
10
10
 
11
- `tuple-ts` gives tuples value-based identity through *interning*:
11
+ `tuple-ts` provides opt-in _value-based identity_:
12
12
 
13
13
  ```ts
14
14
  import { tuple } from "tuple-ts";
15
15
 
16
- tuple(1, 2) === tuple(1, 2); // true
16
+ tuple(1, "foo") === tuple(1, "foo"); // true
17
17
  ```
18
18
 
19
- Equivalent tuples created through the same tuple cache are canonicalised to the same object. Values are frozen, so their contents cannot change after interning. This makes tuples useful as composite `Map` keys, `Set` members, memoization keys, and immutable values in functional TypeScript.
19
+ with optional slot labels:
20
20
 
21
- `tuple-ts` also supports **named tuples** through `struct()`: values remain arrays with positional slots, while the declared labels are available as named properties.
21
+ ```ts
22
+ import { struct } from "tuple-ts";
23
+
24
+ const Person = struct(["name", "age"]);
25
+
26
+ const person1 = Person("Bob", 25);
27
+
28
+ person1[0] === "Bob"; // true
29
+ person1.name === "Bob"; // true
30
+ ```
31
+
32
+ and support for label-based construction:
22
33
 
23
- **Zero dependencies · Less than 1 KB gzipped**
34
+ ```ts
35
+ const person2 = Person.from({ name: "Bob", age: 25 });
36
+
37
+ person1 === person2; // true
38
+ ```
39
+
40
+ `tuple-ts` is useful when you want small immutable values with stable identity:
41
+
42
+ - Composite `Map` and `Set` keys
43
+ - Memoization keys
44
+ - Graph and search states
45
+ - Coordinates and other multi-dimensional values
46
+ - Named, slot-based values such as points or pairs
47
+
48
+ `tuple-ts` has zero dependencies and is less than 1 KB gzipped.
24
49
 
25
50
  ## Install
26
51
 
@@ -30,17 +55,34 @@ npm install tuple-ts
30
55
 
31
56
  ## Tuples
32
57
 
33
- Create a tuple with narrow TypeScript inference:
58
+ The values returned by `tuple(...)` are regular frozen JavaScript arrays:
34
59
 
35
60
  ```ts
36
- import { tuple } from "tuple-ts";
61
+ const tuple1 = tuple(1, "foo");
62
+
63
+ Array.isArray(tuple1); // true
64
+ tuple1.length === 2; // true
65
+ tuple1[0] === 1; // true
66
+ tuple1[1] === "foo"; // true
67
+ Object.isFrozen(tuple1); // true
68
+ [ ...tuple1 ]; // [1, "foo"]
69
+ ```
37
70
 
38
- const point = tuple(10, 20);
39
- // ^? readonly [10, 20]
71
+ ...with automatically inferred literal types:
40
72
 
41
- point[0]; // 10
42
- point[1]; // 20
43
- Object.isFrozen(point); // true
73
+ ```ts
74
+ type Tuple1 = typeof tuple1; // readonly [1, "foo"]
75
+ ```
76
+
77
+ Type-narrowed tuple factories are also supported:
78
+
79
+ ```ts
80
+ type Point = [number, number];
81
+ const Point = tuple.narrow<Point>();
82
+ // Or equivalently, using an exemplar:
83
+ // const Point = tuple.narrow(0, 0);
84
+
85
+ type P = Parameters<typeof Point>; // [number, number];
44
86
  ```
45
87
 
46
88
  Calling `tuple()` again with the same values returns the same canonical object:
@@ -56,117 +98,60 @@ Object.is(a, b); // true
56
98
  Different values or a different element order produce different tuples:
57
99
 
58
100
  ```ts
59
- tuple(10, 20) === tuple(10, 21); // false
60
- tuple(10, 20) === tuple(20, 10); // false
61
- ```
62
-
63
- Tuples are ordinary frozen arrays:
64
-
65
- ```ts
66
- const t = tuple("hello", 42, true);
67
-
68
- t.length; // 3
69
- t[0]; // "hello"
70
- [...t]; // ["hello", 42, true]
71
- Array.isArray(t); // true
72
- Object.isFrozen(t); // true
101
+ tuple(10, 20) === tuple(10, 21); // false!
102
+ tuple(10, 20) === tuple(20, 10); // false!
73
103
  ```
74
104
 
75
105
  ### Constraining a tuple factory with `.narrow()`
76
106
 
77
- Use `.narrow()` to make a reusable factory whose arguments must satisfy a tuple type.
107
+ Use `.narrow()` to make a reusable tuple factory whose arguments must satisfy a nominated tuple type.
78
108
 
79
109
  Specify the type explicitly:
80
110
 
81
111
  ```ts
82
112
  const Point = tuple.narrow<[number, number]>();
83
113
 
84
- const a = Point(10, 20);
85
- const b = Point(10, 20);
86
-
87
- a === b; // true
114
+ Point(10, 20); // OK
88
115
  Point(10, "20"); // TypeScript error
89
116
  ```
90
117
 
91
- You can also refine an existing fixed-length tuple factory using an exemplar. The exemplar is used to infer the constraint, not as a default value:
118
+ You can alternatively provide an exemplar from which the type constraint will be inferred:
92
119
 
93
120
  ```ts
94
- const Pair = tuple.narrow<[unknown, unknown]>();
95
- const NumberPair = Pair.narrow([0, 0]);
121
+ const NumberPair = tuple.narrow(0, 0);
96
122
 
97
- NumberPair(10, 20); // OK
123
+ NumberPair(10, 20); // OK
98
124
  NumberPair(10, "20"); // TypeScript error
99
125
  ```
100
126
 
101
- In this example, the parent factory establishes the two-slot shape, and the exemplar narrows the element types to numbers. `.narrow()` is a TypeScript constraint; it does not create a new cache. Narrowed factories continue to use the shared tuple cache, so equal tuple values are interned together regardless of which narrowed factory constructs them.
102
-
103
- ## Why is this useful?
104
-
105
- ### Composite `Map` keys
106
-
107
- Without interning, separately constructed arrays don't work as value-based keys:
108
-
109
- ```ts
110
- const map = new Map();
111
-
112
- map.set([10, 20], "visited");
113
- map.get([10, 20]); // undefined
114
- ```
115
-
116
- With `tuple-ts`:
117
-
118
- ```ts
119
- const map = new Map();
120
-
121
- map.set(tuple(10, 20), "visited");
122
- map.get(tuple(10, 20)); // "visited"
123
- ```
124
127
 
125
- The tuple used for lookup is the same canonical object as the tuple used as the key. This is useful for coordinates, memoization keys, graph nodes, dynamic-programming states, and other composite values.
126
-
127
- ### `Set` of values
128
-
129
- ```ts
130
- const visited = new Set();
131
-
132
- visited.add(tuple(10, 20));
133
-
134
- visited.has(tuple(10, 20)); // true
135
- visited.has(tuple(10, 21)); // false
136
- ```
137
-
138
- No serialization, stringification, or custom hashing is required.
139
-
140
- ## Nested tuples
128
+ ### Nesting tuples
141
129
 
142
130
  Tuples can contain other interned tuples:
143
131
 
144
- ```ts
145
- const a = tuple("matrix", tuple(1, 0), tuple(0, 1));
146
- const b = tuple("matrix", tuple(1, 0), tuple(0, 1));
147
-
148
- a === b; // true
149
- ```
150
-
151
- Because the inner tuples are themselves canonicalised, the outer tuples can be interned as well.
152
-
153
132
  ```ts
154
133
  const a = tuple("a", tuple("b", "c"));
155
134
  const b = tuple("a", tuple("b", "c"));
156
135
  const c = tuple("a", tuple("b", "d"));
157
136
 
158
137
  a === b; // true
159
- a === c; // false
138
+ a === c; // false!
139
+
140
+ const mat1 = tuple("matrix", tuple(1, 0), tuple(0, 1));
141
+ const mat2 = tuple("matrix", tuple(1, 0), tuple(0, 1));
142
+
143
+ mat1 === mat2; // true
160
144
  ```
161
145
 
162
- ## Named tuples with `struct()`
146
+ ## Structs
163
147
 
164
- Use `struct()` when you want positional tuple storage with meaningful labels. Pass a readonly label list to create a reusable factory:
148
+ Use `struct()` when you want a tuple with meaningful labels.
149
+ Provide a list of labels to create a reusable factory that produces tuples with additional named properties.
165
150
 
166
151
  ```ts
167
152
  import { struct } from "tuple-ts";
168
153
 
169
- const Point = struct(["x", "y"]).narrow<[number, number]>();
154
+ const Point = struct(["x", "y"]);
170
155
 
171
156
  const p = Point(10, 20);
172
157
 
@@ -175,26 +160,24 @@ p[1]; // 20
175
160
  p.x; // 10
176
161
  p.y; // 20
177
162
 
178
- Array.isArray(p); // true
163
+ Array.isArray(p); // true!
179
164
  Object.isFrozen(p); // true
165
+ [ ...p ]; // [10, 20]
166
+ { ...p }; // { 0: 10, 1: 20, x: 10, y: 20 }
180
167
  ```
181
168
 
182
- The label order corresponds to the tuple slot order: the first label names slot `0`, the second names slot `1`, and so on. The named properties are added to the array when it is interned. They are own, enumerable properties and are frozen along with the array.
183
-
184
- ### Constructing from a named object with `.from()`
169
+ ### Constructing from an object with `.from()`
185
170
 
186
171
  `.from()` accepts an object with the declared labels and creates the corresponding positional tuple:
187
172
 
188
173
  ```ts
189
- const Point = struct(["x", "y"]).narrow<[number, number]>();
174
+ const Point = struct(["x", "y"]);
190
175
 
191
- const a = Point.from({ x: 10, y: 20 });
192
- const b = Point.from({ y: 20, x: 10 });
176
+ const point1 = Point.from({ x: 10, y: 20 });
193
177
 
194
- a === b; // true
195
-
196
- a[0]; // 10
197
- a.x; // 10
178
+ point1 === Point(10, 20); // true
179
+ point1[0] === 10; // true
180
+ point1.x === 10; // true
198
181
  ```
199
182
 
200
183
  The values are read in the schema's label order, regardless of the input object's property order. Extra properties on the input object are ignored:
@@ -210,7 +193,7 @@ The returned value contains the tuple slots and the schema's named properties, n
210
193
 
211
194
  ### Constraining a struct factory
212
195
 
213
- Use `.narrow()` to specify the tuple's positional types:
196
+ Just like `tuple`, structs support `.narrow()` to specify the struct's value types positionally:
214
197
 
215
198
  ```ts
216
199
  const Point = struct(["x", "y"]).narrow<[number, number]>();
@@ -227,24 +210,19 @@ type Coordinates = { x: number; y: number };
227
210
  const Point = struct(["x", "y"]).narrowFrom<Coordinates>();
228
211
 
229
212
  Point(10, 20); // OK
230
- Point(10, "20"); // TypeScript error
213
+ Point(10, "20"); // TypeScript error
231
214
  Point.from({ x: 10, y: 20 }); // OK
232
215
  ```
233
216
 
234
217
  You can also provide a typed exemplar to `.narrowFrom()`:
235
218
 
236
219
  ```ts
237
- const coordinateExample: Coordinates = { x: 0, y: 0 };
238
- const Point = struct(["x", "y"]).narrowFrom(coordinateExample);
220
+ const Point = struct(["x", "y"]).narrowFrom({ x: 0, y: 0 });
239
221
  ```
240
222
 
241
- The exemplar establishes types; its values are not used as defaults. If you pass an inline `const` exemplar whose properties are literal values, those literal types may be preserved. Give the exemplar a widened type, or use an explicit generic argument, when you want types such as `number` rather than the literal type `0`.
242
-
243
- Both `.narrow()` and `.narrowFrom()` preserve the cache associated with the original `struct()` schema. They refine the factory's TypeScript constraints rather than creating a new interning domain.
244
-
245
223
  ### Struct schemas and identity
246
224
 
247
- Each call to `struct(labels)` creates a schema with its own intern cache. Factories narrowed from that schema share the cache, but separate calls to `struct()` create separate caches, even when given identical labels:
225
+ Each call to `struct(labels)` creates its own cache. Factories narrowed from that schema share the cache, but separate calls to `struct()` create separate caches, even when given identical labels:
248
226
 
249
227
  ```ts
250
228
  const PointA = struct(["x", "y"]).narrow<[number, number]>();
@@ -256,9 +234,40 @@ PointA(1, 2) === PointB(1, 2); // false
256
234
 
257
235
  Struct identity is therefore scoped to the schema factory. Ordinary `tuple()` values use a separate, shared tuple cache and do not share identity with struct values, even when their indexed elements are equal.
258
236
 
259
- ### Choosing labels
237
+ ## Tuples and structs as composite keys for `Map` and `Set`
238
+
239
+ `tuple` and `struct` let you use composite keys with JavaScript's built-in `Map` and `Set`, without serialization or stringification.
240
+
241
+ JavaScript's `Map` and `Set` treat separately constructed arrays as distinct keys, even when their contents are equal:
242
+
243
+ ```ts
244
+ const myMap = new Map();
245
+ myMap.set([10, 20], "visited");
246
+
247
+ myMap.has([10, 20]); // false!
248
+ myMap.get([10, 20]); // undefined!
249
+
250
+ const mySet = new Set();
251
+ mySet.add([10, 20]);
252
+
253
+ mySet.has([10, 20]); // false!
254
+ ```
255
+
256
+ With `tuple-ts`:
257
+
258
+ ```ts
259
+ const myMap = new Map();
260
+ myMap.set(tuple(10, 20), "visited");
260
261
 
261
- Use distinct labels, and avoid labels that collide with array properties or indices. In particular, `"length"`, numeric-looking labels such as `"0"`, and method names such as `"map"` can conflict with normal array behaviour. The current implementation does not validate label names or duplicate labels, so callers should ensure labels are unique and suitable for named properties.
262
+ myMap.has(tuple(10, 20)); // true
263
+ myMap.get(tuple(10, 20)); // "visited"
264
+
265
+ const Point = struct(["x", "y"]);
266
+ const mySet = new Set();
267
+ mySet.add(Point(1, 2));
268
+
269
+ mySet.has(Point(1, 2)); // true
270
+ ```
262
271
 
263
272
  ## Equality semantics
264
273
 
@@ -278,7 +287,7 @@ tuple(value) === tuple(value); // true
278
287
  tuple({ x: 1 }) === tuple({ x: 1 }); // false
279
288
  ```
280
289
 
281
- `tuple-ts` does not recursively compare arbitrary JavaScript objects. Nested tuples work well because they are themselves interned:
290
+ `tuple-ts` does not recursively compare arbitrary JavaScript objects. Nested tuples work well because the values are themselves tuples:
282
291
 
283
292
  ```ts
284
293
  const a = tuple(tuple(1, 2));
@@ -287,45 +296,11 @@ const b = tuple(tuple(1, 2));
287
296
  a === b; // true
288
297
  ```
289
298
 
290
- Struct caches use the same shallow slot-value comparison, but each struct schema has its own cache. Within a schema, the label properties are determined by the schema; the indexed slot values determine which canonical tuple is returned.
291
-
292
- ## Immutable by design
293
-
294
- Tuples and structs returned by `tuple-ts` are frozen. Freezing is shallow, just like `Object.freeze()`:
295
-
296
- ```ts
297
- const details = { name: "Alice" };
298
- const value = tuple(details);
299
-
300
- details.name = "Bob"; // Allowed: the nested object itself is not frozen
301
- ```
302
-
303
- The tuple's reference to `details` is unchanged, so its interned identity remains the same. If you need immutable nested values, construct those values with `tuple()` or `struct()` too.
304
-
305
- ## Garbage-collection friendly
306
-
307
- Interning raises a natural question: *doesn't the cache grow forever?* The cache implementation uses weak references and finalization support so that values no longer referenced by the application can be reclaimed by the JavaScript garbage collector. Values are not intended to be kept alive forever simply because they were once constructed.
308
-
309
- ## When should I use it?
310
-
311
- `tuple-ts` is useful when you want small immutable values with stable identity:
312
-
313
- - Composite `Map` keys
314
- - `Set` membership
315
- - Memoization keys
316
- - Graph and search states
317
- - Coordinates and other multi-dimensional values
318
- - Functional programming
319
- - Immutable application state
320
- - Named, slot-based values such as points or pairs
321
-
322
- Use `tuple()` for positional values and `struct()` when the same kind of value benefits from stable field labels.
323
-
324
- ## Design
299
+ ## Garbage Collection
325
300
 
326
- The intern cache uses hashing to find candidates, then checks exact shallow equality before reusing an existing value. Hash collisions therefore do not determine equality. Tuple element order matters. Struct label order defines the mapping between slots and names, and each schema has its own cache.
301
+ `tuple-ts` works by maintaining internal caches of tuple/struct objects. This raises a natural question: *doesn't the cache grow forever?*
327
302
 
328
- There are no runtime dependencies.
303
+ The cache uses `WeakRef` to avoid keeping interned objects alive solely because they are cached. A `FinalizationRegistry` is used to clean up cache entries after objects are garbage-collected. This allows unreferenced values to be reclaimed without the cache retaining them indefinitely.
329
304
 
330
305
  ## License
331
306