⚠️ Disclaimer: We're about to talk about the parts of JavaScript that you should, realistically, never use in product code; about APIs so powerful, their mere presence in your code base will highly likely tear the fabric of the universe to shreads. Treat this as an educational piece that it is. You've been warned.
There I was, working on a test setup for the project that deals with files, and I found myself writing a utility like this:
1export function toFileInit(source: string, type: MIMEType) {2 return [toUint8Array(source), 'text-file.txt', { type }]3}
The idea is to use this list of serializable data to create File instances in a different context (e.g. on a page in a Playwright test):
1// Node.js context.2const fileInit = toFileInit(...)34await page.evaluate((fileInit) => {5 // Browser context.6 const file = new File(...fileInit)7}, fileInit)
But as I kept working with this utility, I found myself wanting to reference some of the data it derives from my source and type. The thing is, I didn't like this kind of syntax:
1const fileInit = toFileInit(...)2await expect(something).toBe(fileInit[0])
Because what even is fileInit[0]? Well, it's the file's bytes, but can you truly tell? Value access by index doesn't have the best ergonomics and even type observability suffers here unless you assign the thing to a variable. But what if I don't want to do that?
I can always return an object from my toFileInit and have things like fileInit.bytes, but that would mean its return value can no longer be spread as arguments to create a File. Sounds like I need to choose which behavior I value more or resort to another abstraction, right?
Wrong.
I can actually have both with a slight change to my function. And to understand how, let's first unwrap what the spread syntax (...) actually is and what it does to the value it spreads.
The spread syntax
You've likely used the spread syntax before. Either like this:
1const prevArray = [1, 2]2const nextArray = [...prevArray]3// 1, 2
...or like this:
1const prevObject = { a: 1, b: 2 }2const nextObject = { ...prevObject }3// { a: 1, b: 2 }
...or, perhaps, even like this:
1const map = new Map([['a', 1], ['b', 2]])2const list = [...map]3// [['a', 1], ['b', 2]]
It spreads the values, what is there more to say? Well, nothing. If you're talking about what it does. Today, we are really interested in how that spreading happens.
We pay it no mind when talking about arrays and objects because it has become intuitive to us. They are boxes of values and we can extract whatever goods they have inside and assign them to other boxes. It becomes far more interesting when we put primitives like Set and Map on our examination table.
A Map is not an array and neither is it an object. If you log out a map, you will see this thing:
Map(2) {'a' => 1, 'b' => 2}
So how does JavaScript know that when you want to spread this map above it should be [['a', 1], ['b', 2]] and not, say, ['a', 'b'] or [1, 2]? How does it decide the way the goods inside weird-looking box should be represented when spread?
It doesn't. Map itself tells the language how its values must be spread using Symbol.iterator.
Symbol.iterator
The concept of symbols in JavaScript isn't new. It's an API designed to represent unique values and it's often used to prevent internal and user-defined object keys from clashing:
1const foo = Symbol('foo')23const entity = {4 foo: 'user-defined',5 [foo]: 'internal'6}
Despite both of those keys looking like
"foo", you can never re-define or even access the value behindSymbol('foo') unless you have that exact symbol reference.
While you can create your custom symbols, there are also a bunch of built-in ones that are exposed to you by the language to help you control certain behaviors, like representing a value when it's passed to JSON.stringify(), or disposing of an object, or... spreading its values.
Yep, you can tell JavaScript what ...myObject returns by defining the Symbol.iterator on myObject:
1const myObject = {2 a: 1,3 b: 2,4 [Symbol.iterator]: function*() {5 yield 'hello'6 yield 'world'7 }8}910console.log(...myObject)11// "hello world"
The value of the
Symbol.iteratormust be an iterator. In this example, I'm using a generator function, which is one of the ways to declare an iterator, and it yields the values for anybody consumingSymbol.iterator, like thefor..ofloops or the spread syntax!
Not only is this a legal feature built into the language, but a great tool in your API designing arsenal.
Back to our muttons
Equipped with this knowledge, we can refactor the toFileInit function to both be an object and spread to a list of arguments accepted by the File constructor:
1export function toFileInit(source: string, type: MIMEType) {2 const bytes = toUint8Array(source)3 const name = 'text-file.txt'45 return {6 bytes,7 name,8 type,9 [Symbol.iterator]: function*() {10 yield [[bytes], name, options]11 }12 }13}
And here's how you push this further and make Symbol.iterator type safe:
1export function toFileInit(source: string, type: MIMEType): {2 bytes: Uint8Array,3 name: string4 type: MIMEType,5 [Symbol.iterator]: () => IterableIterator<6 [[Uint8Array<ArrayBuffer>], string, Partial<FileOptions>],7 void8 >9} {/* ... */}
Now that toFileInit controls how its values are spread, I can finally use it the way I want.
1const fileInit = toFileInit(...)23await page.evaluate((fileInit) => {4 new File(...fileInit)5}, [...fileInit])67await expect(something).toEqual(fileInit.bytes)
Note that
Symbol.iteratordoes not survive object serialization since its value is a function and functions are, generally, not serializable. That's why I'm spreading the values before passing it topage.evaluate. This is a Playwright's thing, you can ignore this detail.
