Here is a function that respects the built-in JSON.stringify() rules while also limiting depth. This version handles cyclical references by making them either null, or using an optional callback to get an object ID (such as a GUID).
function stringify(val, depth, replacer, space, onGetObjID) {
    depth = isNaN(+depth) ? 1 : depth;
    var recursMap = new WeakMap();
    function _build(val, depth, o, a, r) { // (JSON.stringify() has it's own rules, which we respect here by using it for property iteration)
        return !val || typeof val != 'object' ? val
            : (r = recursMap.has(val), recursMap.set(val,true), a = Array.isArray(val),
               r ? (o=onGetObjID&&onGetObjID(val)||null) : JSON.stringify(val, function(k,v){ if (a || depth > 0) { if (replacer) v=replacer(k,v); if (!k) return (a=Array.isArray(v),val=v); !o && (o=a?[]:{}); o[k] = _build(v, a?depth:depth-1); } }),
               o===void 0 ? (a?[]:{}) : o);
    }
    return JSON.stringify(_build(val, depth), null, space);
}
var o = {id:'SOMEGUID',t:true};
var value={a:[12,2,{y:3,z:{o1:o}}],s:'!',b:{x:1,o2:o,o3:o}};
console.log(stringify(value, 0, (k,v)=>{console.log('key:'+k+';val:',v); return v}, 2));
console.log(stringify(value, 1, (k,v)=>{console.log('key:'+k+';val:',v); return v}, 2));
console.log(stringify(value, 2, (k,v)=>{console.log('key:'+k+';val:',v); return v}, 2));
console.log(stringify(value, 3, (k,v)=>{console.log('key:'+k+';val:',v); return v}, 2));
console.log(stringify(value, 4, (k,v)=>{console.log('key:'+k+';val:',v); return v}, 2, (v)=>{return v.id}));
{}
{
  "a": [
    12,
    2,
    {}
  ],
  "s": "!",
  "b": {}
}
{
  "a": [
    12,
    2,
    {
      "y": 3,
      "z": {}
    }
  ],
  "s": "!",
  "b": {
    "x": 1,
    "o2": {},
    "o3": null
  }
}
{
  "a": [
    12,
    2,
    {
      "y": 3,
      "z": {
        "o1": {}
      }
    }
  ],
  "s": "!",
  "b": {
    "x": 1,
    "o2": null,
    "o3": null
  }
}
{
  "a": [
    12,
    2,
    {
      "y": 3,
      "z": {
        "o1": {
          "id": "SOMEGUID",
          "t": true
        }
      }
    }
  ],
  "s": "!",
  "b": {
    "x": 1,
    "o2": "SOMEGUID",
    "o3": "SOMEGUID"
  }
(taken from my post here https://stackoverflow.com/a/57193068/1236397)
Here is a TypeScript version:
/** A more powerful version of the built-in JSON.stringify() function that uses the same function to respect the
 * built-in rules while also limiting depth and supporting cyclical references.
 */
export function stringify(val: any, depth: number, replacer: (this: any, key: string, value: any) => any, space?: string | number, onGetObjID?: (val: object) => string): string {
    depth = isNaN(+depth) ? 1 : depth;
    var recursMap = new WeakMap();
    function _build(val: any, depth: number, o?: any, a?: boolean, r?: boolean) {
        return !val || typeof val != 'object' ? val
            : (r = recursMap.has(val),
                recursMap.set(val, true),
                a = Array.isArray(val),
                r ? (o = onGetObjID && onGetObjID(val) || null) : JSON.stringify(val, function (k, v) { if (a || depth > 0) { if (replacer) v = replacer(k, v); if (!k) return (a = Array.isArray(v), val = v); !o && (o = a ? [] : {}); o[k] = _build(v, a ? depth : depth - 1); } }),
                o === void 0 ? (a?[]:{}) : o);
    }
    return JSON.stringify(_build(val, depth), null, space);
}
Note: Arrays are treated like strings - an array of primitive values; thus, any nested object items are treated as the next level instead of the array object itself (much like how a string can be an array of characters, but is one entity).
Update: Fixed a bug where empty arrays rendered as empty objects.