2

I have a simple object:

   let obj = {
      season: 'winter',
      data: {
          month: ['December', 'January', 'February']
         }
    };

I need to get a string like:

`season=winter&data[month][0]=December&data[month][1]=January&data[month][2]=February`

I tried but I didn't completely succeed

function convertToText(obj) {
  let string = [];
  if (typeof(obj) == "object") {
    for (prop in obj) {
      if (obj.hasOwnProperty(prop))
        string.push(prop + "=" + convertToText(obj[prop]));
    };
    return string.join("&");
  } else {
    string.push(JSON.stringify(obj))
  }

  return string.join();
}

let obj = {
  season: 'winter',
  data: {
    month: ['December', 'January', 'February']
  }
};

console.log(convertToText(obj));

and gets this:

"season='winter'&data=month=0='December'&1='January'&2='February'"
1
  • Consider using an established serialization format, such as JSON, if possible. Commented Oct 3, 2022 at 10:50

3 Answers 3

3

It's often helpful to keep around a collection of utility functions. I have one, pathEntries, that creates something like what Object.entries does, but instead of a string key, it has an array of string/integer keys giving the whole path in a nested object. The version used here includes only paths to root nodes. It would convert your input into this format:

[
  [["season"], "winter"],
  [["data", "month", 0], "December"],
  [["data", "month", 1], "January"],
  [["data", "month", 2], "February"]
]

Using that, it's quite simple:

const pathEntries = (obj) => Object (obj) === obj
  ? Object .entries (obj) .flatMap (([k, x]) => pathEntries (x) .map (
      ([p, v]) => [[Array .isArray (obj) ? Number (k) : k, ... p], v] 
    )) 
  : [[[], obj]]

const toQuery = (obj) => pathEntries (obj) .map (
  ([[p, ...ps], v]) => `${p}${ ps .map (n => `[${ n }]`) .join ('') }=${ v }`
) .join ('&')


const obj = {season: 'winter', data: {month: ['December', 'January', 'February']}}

console .log (toQuery (obj))

Because we know that the paths cannot be empty, we can comfortably extract the first element from them and treat it differently. (It's not wrapped in [ - ]), and we can just build one part of our response string directly using the first node in the path, p, the remaining nodes, ps, and the value, v. Then we join these parts together with &, and we're done.

pathEntries is more interesting. If we're not working on an object, we simply return an array containing a single pair: an empty array for the path, and our input value. If it is an Object, then we use Object .entries to break it down into key-value pairs, recur on the values, and prepend the key to the path of each result.

For this problem we could simplify by replacing this line:

      ([p, v]) => [[Array .isArray (obj) ? Number (k) : k, ... p], v] 

with

      ([p, v]) => [[k, ... p], v] 

The original version yields enough information to reconstitute objects including arrays nested at any depth. With this change, we would turn them into plain objects with keys like "0", "1", etc. That's fine for this problem. But I usually choose the more powerful version, because I might want to reuse it in multiple places in an application.

Notes

User Mulan suggested that continually checking to see whether our key is numeric while we recur is sub-optimal. There are many ways to fix that, including storing it in an IIFE, using a call/bind helper, or using (abusing?) the fact that Object .entries returns a collection of pairs, two-element arrays, to default a third parameter, as demonstrated here:

const pathEntries = (obj) => Object (obj) === obj
  ? Object .entries (obj) .flatMap (
      ([k, x, k1 = Array .isArray (obj) ? Number (k) : k]) => 
        pathEntries (x) .map (([p, v]) => [[k1, ... p], v])
    ) 
  : [[[], obj]]

Mulan also mentioned that the URLSearchParams API is a much better way to build robust URLs. This is entirely correct. We can do so with something like:

const pathEntries = (obj) => Object (obj) === obj
  ? Object .entries (obj) .flatMap (
      ([k, x, k1 = Array .isArray (obj) ? Number (k) : k]) => 
        pathEntries (x) .map (([p, v]) => [[k1, ... p], v])
    ) 
  : [[[], obj]]

const toQuery = (obj) => pathEntries (obj) .map (
  ([[p, ...ps], v]) => [`${p}${ ps .map (n => `[${ n }]`) .join ('') }`, v]
) .reduce ((usp, [k, v]) => ((usp .append (k, v)), usp), new URLSearchParams (''))


const obj = {season: 'winter', data: {month: ['December', 'January', 'February']}}

console .log (toQuery (obj) .toString()) 

Note that the brackets are now correctly encoded. However this is not the exact output that was requested. You will have to decide if that's appropriate for your needs.

Sign up to request clarification or add additional context in comments.

7 Comments

good hygiene keeping array indices numeric, i just wish there was an easier way to avoid repeating the isArray check for every leaf. i would suggest URLSearchParams for toQuery to ensure proper encoding of everything. nice to see you :D
@Mulan: I was just considering the same thing as I answered this. We could do so easily enough, but I thought it was a bit afield for a problem like this, the same reason I actually mentioned the string index alternative. Give me a few moments...
@Mulan: And, yes, I meant to suggest that URLSearchParams is a much more robust solution. I was assuming that the question was more along the lines of "how would I do this myself?" Good to see you too!
@Mulan: Updated with more efficient pathEntries and a version of toQuery which uses URLSearchParams.
Maybe more along the lines of const pathEntries = (obj, isArray = Array.isArray(obj)) => ..., which avoids re-evaluation and the awkward 3-tuple? Still imperfect but ya, I guess bind or call is essential.
|
2

Recursively walk through the entire object hierarchy, keeping track of the path which will be used as prefix for the "key=value" pairs.

Arrays can be handled specially - the key for an array value, as well as any of its indexes need to be wrapped in square brackets.

Finally, the base case would be to convert the prefix to the left side of the equal and a plain value as the right side.

const isPlainObject = data =>
  typeof data === "object" 
    && data !== null 
    && !Array.isArray(data);

const arrayKey = (prefix, index) =>
  prefix
    //take every element except the last one:
    .slice(0, -1)
    //append last element and index enclosed in square brackets:
    .concat(`[${prefix.at(-1)}]`, `[${index}]`); 

function convertToText(data, prefix = []) {
  if (isPlainObject(data))
      return Object.entries(data)
        .map(([key, value]) => convertToText(value, prefix.concat(key)))
        .join("&");
  
  if (Array.isArray(data))
    return data
        .map((x, index) => convertToText(x, arrayKey(prefix, index)))
        .join("&")
    
    return `${prefix.join("")}=${data}`;
};

let obj = {
  season: 'winter',
  data: {
    month: ['December', 'January', 'February']
  }
};

console.log(convertToText(obj));

Comments

2

Given obj -

const obj = {
  season: 'winter',
  data: {
    month: ['December', 'January', 'February']
  }
}

Start with a generic flat function to flatten the data -

function *flat(t){
  switch (t?.constructor) {
    case Object:
    case Array:
      for (const [k,v] of Object.entries(t))
        for (const [path, value] of flat(v))
          yield [[k, ...path], value]
      break
    default:
      yield [[], t]
  } 
}
for (const [path, value] of flat(obj))
  console.log(path.join("/"), value)
season winter
data/month/0 December
data/month/1 January
data/month/2 February

Use URLSearchParams to encode the params -

function toSearchParams(t) {
  const r = new URLSearchParams()
  for (const [path, value] of flat(t))
    r.append(
      [path[0], ...path.slice(1).map(v => `[${v}]`)].join(""),
      value
    )
  return r
}
console.log(toSearchParams(obj))
URLSearchParams {
  'season' => 'winter',
  'data[month][0]' => 'December',
  'data[month][1]' => 'January',
  'data[month][2]' => 'February'
}

URLSearchParams can be converted to a string -

console.log(String(toSearchParams(obj)))
console.log(decodeURIComponent(String(toSearchParams(obj))))
season=winter&data%5Bmonth%5D%5B0%5D=December&data%5Bmonth%5D%5B1%5D=January&data%5Bmonth%5D%5B2%5D=February
season=winter&data[month][0]=December&data[month][1]=January&data[month][2]=February

See url.searchParams for use with the URL module that you should be leveraging as well.


As a shortcut above, I treat both object keys and array keys as strings. If we want flat to preserve numeric keys for array values, we can write a different outer loop for each type -

function *flat(t){
  switch (t?.constructor) {
    case Object:
      for (const k of Object.keys(t))
        for (const [path, value] of flat(t[k]))
          yield [[k, ...path], value]   // k: string
      break
    case Array:
      for (let k = 0; k < t.length; k++)
        for (const [path, value] of flat(t[k]))
          yield [[k, ...path], value]   // k: number
      break
    default:
      yield [[], t]
  } 
}

We could collapse the inner loop using an optional path parameter -

function *flat(t, path = []){
  switch (t?.constructor) {
    case Object:
      for (const k of Object.keys(t))
        yield *flat(t[k], [...path, k])
      break
    case Array:
      for (let k = 0; k < t.length; k++)
        yield *flat(t[k], [...path, k])
      break
    default:
      yield [path, t]
  } 
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.