Proxy 的 set trap 返回 falsy 触发严格模式 TypeError 的边界

在 ECMAScript 里用 Proxy 做数据校验、埋点拦截或响应式系统时,set trap 返回 false 是最容易被低估的一个细节:非严格模式下它静默失败,严格模式下它抛出 TypeError: 'set' on proxy: trap returned falsish。更麻烦的是,这道错误来自引擎内部([[Set]] 内部方法的断言),而不是来自

在 ECMAScript 里用 Proxy 做数据校验、埋点拦截或响应式系统时,set trap 返回 false 是最容易被低估的一个细节:非严格模式下它静默失败,严格模式下它抛出 TypeError: 'set' on proxy: trap returned falsish。更麻烦的是,这道错误来自引擎内部([[Set]] 内部方法的断言),而不是来自你自己的 throw,调用栈里看不到业务代码,初学者往往以为是 Proxy 写错了。这篇文章把 set trap 返回 falsy 的判定规则、严格模式边界、与 Reflect.set 返回值的关系、以及可落地的统一写法讲清楚,并给出真实报错与排查清单。

一、先复现:一行 return false 在两个模式下的两种命运

先把现象固定下来,后面所有讨论都围绕这段代码展开。

const target = {};
const handler = {
  set(obj, key, value, receiver) {
    if (key === 'age' && !Number.isInteger(value)) {
      return false; // 拒绝写入
    }
    obj[key] = value;
    return true;
  }
};

const p = new Proxy(target, handler);

// 非严格模式(脚本顶层、普通函数体)
p.age = 3.14;
console.log(p.age); // undefined
console.log(target.age); // undefined
console.log('非严格模式:静默失败,不抛错');

// 严格模式(ES module、class 方法体、'use strict' 之后)
function strictAssign() {
  'use strict';
  p.age = 3.14;
}

try {
  strictAssign();
} catch (e) {
  console.log(e.constructor.name); // TypeError
  console.log(e.message);
  // 'set' on proxy: trap returned falsish for property 'age'
}

同一段 handler,同一个赋值语句,唯一的差别是执行赋值的代码是否处于严格模式。这就是规范里 [[Set]] 的语义:set trap 返回 false 时,[[Set]] 返回 false(表示赋值未被接受);而赋值表达式在严格模式下必须检查这个 false,并抛出 TypeError。

关键认知:这个 TypeError 不是 handler 抛的,是引擎在 set trap 返回之后、赋值表达式求值结束之前补抛的。所以你在 handler 里 try/catch 是抓不到的。

二、规范视角:falsish 到底是什么,谁在检查它

2.1 set trap 的返回值语义

按 ECMAScript 规范,OrdinarySetWithOwnDescriptor 之外,Proxy 的 [[Set]] 内部方法在调用 set trap 之后会做两步:

  • 拿到 trap 的返回值 booleanTrapResult;
  • 若 booleanTrapResult 为 false,则 [[Set]] 直接返回 false;若为 true,还要继续做一致性检查(见 2.3)。

注意规范文档里的写法是「If booleanTrapResult is false」,也就是它对返回值先做了一次 ToBoolean 语义的判断——「falsish」指的就是 ToBoolean 结果为 false 的任何值:false、0、-0、0n、""、null、undefined、NaN。很多人以为只有 return false 才会触发,实际上 return 0 或者忘记写 return(得到 undefined)同样会触发。

const cases = [false, 0, -0, 0n, '', null, undefined, NaN];

for (const v of cases) {
  const p = new Proxy({}, {
    set() {
      return v;
    }
  });

  try {
    // 严格模式下的赋值才抛
    (function () {
      'use strict';
      p.x = 1;
    })();
    console.log(String(v), '=> 没有抛错');
  } catch (e) {
    console.log(String(v), '=>', e.message);
  }
}
// false   => 'set' on proxy: trap returned falsish for property 'x'
// 0       => 'set' on proxy: trap returned falsish for property 'x'
// 0       => (-0 被 String 成 0,同样报错)
// 0       => (0n 同上)
//         => 'set' on proxy: trap returned falsish for property 'x'
// null    => 'set' on proxy: trap returned falsish for property 'x'
// undefined => 'set' on proxy: trap returned falsish for property 'x'
// NaN     => 'set' on proxy: trap returned falsish for property 'x'

最容易中招的是 undefined:写 handler 时只写了「校验通过就 obj[key] = value」,忘了 return true。非严格模式下一切正常,一旦代码被打包工具转成 ESM 或者放进 class 方法,立刻炸。

2.2 谁在抛 TypeError:赋值表达式的严格模式分支

规范里赋值表达式 AssignmentExpression : LeftHandSideExpression = AssignmentExpression 的求值流程大致是:先求值左侧得到引用 lref,再求值右侧 rval,然后 PutValue(lref, rval)。而在 PutValue 里有一句判定:

// 规范伪代码(对应 PutValue 步骤)
// 1. 若 IsPropertyReference(V) 为 true:
//    a. 若 IsStrictReference(V) 为 true,令 strict 为 true,否则为 false
//    b. 令 baseObj 为 ToObject(V.[[Base]])
//    c. 若 baseObj 是 Proxy 对象,调用 baseObj.[[Set]](...)
// 2. 若操作结果为 false 且 strict 为 true,抛出 TypeError

也就是说,严格性来自「赋值所在代码的严格模式」,而不是 Proxy 或 target 的严格性。target 是不是严格模式创建的对象完全无关。这一点在排查时非常重要,因为报错位置往往离 handler 很远。

2.3 返回 true 也不算完:一致性约束

返回 true 之后,引擎还要验证 trap 的行为是否与 target 的实际状态自洽,违反会抛另一种 TypeError:

  • 若 target 上该属性是不可写且不可配置的数据属性,且 trap 返回 true,但赋值后的值与 target 上的值不同,抛 TypeError: 'set' on proxy: trap returned truish for property 'x' which exists in the proxy target as a non-writable and non-configurable data property with a different value than the one passed to the set trap。
  • 若 target 上该属性是不可配置的访问器属性且没有 setter,trap 返回 true 同样抛错。
const target = {};
Object.defineProperty(target, 'id', {
  value: 1,
  writable: false,
  configurable: false
});

const p = new Proxy(target, {
  set() {
    return true; // 谎报成功
  }
});

try {
  (function () {
    'use strict';
    p.id = 2;
  })();
} catch (e) {
  console.log(e.message);
  // 'set' on proxy: trap returned truish for property 'id' which exists in the
  // proxy target as a non-writable and non-configurable data property with a
  // different value than the one passed to the set trap
}

这类错误和 falsish 是「一对」:一个说你拒绝得太随意,一个说你答应得太随意。设计校验型 Proxy 时两者都要考虑。

三、五种常见踩坑现场

3.1 忘记 return,或被 if 分支吃掉返回值

const store = new Proxy({}, {
  set(obj, key, value) {
    if (typeof value === 'string') {
      obj[key] = value.trim();
      // 这里忘了 return true
    }
    // 走到这里隐式返回 undefined
  }
});

改成显式返回 true,或直接 return Reflect.set(...)。推荐后者,理由见第四节。

3.2 用 return 代替 throw 做业务校验

很多人把「校验失败」写成 return false,期望调用方拿到 false。p.x = v 这个表达式的结果是被赋的值 v,不是 trap 的返回值,所以调用方拿不到 false。想做业务级拒绝,应当 throw 自己的错误类型,这样跨严格/非严格模式行为一致,错误信息也可控。

class ValidationError extends Error {
  constructor(prop, value) {
    super(`属性 ${prop} 不接受值 ${String(value)}`);
    this.name = 'ValidationError';
    this.prop = prop;
    this.value = value;
  }
}

const user = new Proxy({}, {
  set(obj, key, value, receiver) {
    if (key === 'age' && !(Number.isInteger(value) && value >= 0 && value <= 150)) {
      throw new ValidationError(key, value);
    }
    return Reflect.set(obj, key, value, receiver);
  }
});

try {
  user.age = -1;
} catch (e) {
  console.log(e.name, e.message); // ValidationError 属性 age 不接受值 -1
}

3.3 数组 push 的链式调用被误伤

Array.prototype.push 内部会设置索引和 length,并且返回新的 length。如果 set trap 对索引或 length 返回 false,严格模式下 push 直接抛错;更隐蔽的是,有些实现对 length 的写入会被 Reflect.set 的返回值影响。

const arr = new Proxy([], {
  set(obj, key, value, receiver) {
    if (key === 'length') {
      return false; // 禁止改长度
    }
    return Reflect.set(obj, key, value, receiver);
  }
});

// 非严格
try {
  arr.push(1);
  console.log(arr.length); // 1,因为 length 会被索引写入隐式带动
} catch (e) {
  console.log('非严格模式抛错:', e.message);
}

// 严格模式(例如在 module 里)
(function () {
  'use strict';
  try {
    arr.push(2);
  } catch (e) {
    console.log(e.constructor.name, e.message);
    // TypeError 'set' on proxy: trap returned falsish for property 'length'
  }
})();

注意 push 本身在 Array.prototype 上是严格模式代码(内置函数一律按严格语义执行),所以即便调用方是非严格模式,push 内部对 length 的写入仍可能抛错。这类「调用方宽松、被调方严格」的差异是排查难点:报错栈里只有 push,看不到你的赋值语句。

3.4 Object.assign / defineProperty 的传播差异

Object.assign(target, source) 内部用 Set(to, nextKey, propValue, true),最后那个 true 表示「严格语义」,因此 set trap 返回 falsish 必定抛 TypeError,与调用处是否严格无关。

const p = new Proxy({}, {
  set() {
    return false;
  }
});

try {
  Object.assign(p, { a: 1 }); // 即使不在严格模式也抛
} catch (e) {
  console.log(e.message);
  // 'set' on proxy: trap returned falsish for property 'a'
}

对比 Object.defineProperty(p, 'a', { value: 1 }):它走的是 [[DefineOwnProperty]],也就是 defineProperty trap,不是 set trap,所以上面这个 handler 拦不到,也不会抛错。搞混这两个 trap 是另一类高频 bug。

3.5 与 with 语句、解构赋值的组合

解构赋值 ({ a } = p) 最终也是 PutValue,严格模式同样抛错。而 with (p) { a = 1; } 在现代代码里几乎绝迹,但如果你在维护老代码,注意 with 体内的赋值引用其严格性同样跟随所在代码。

四、可靠写法:用 Reflect.set 的返回值作为 trap 返回值

Reflect.set(target, key, value, receiver) 返回布尔值,语义与 [[Set]] 对齐:写入成功返回 true,失败(例如目标属性不可写)返回 false。把它的返回值原样透传,既保留了「拒绝写入」的语义,也避免了 3.1 的漏写问题。

function createValidated(target, validators) {
  return new Proxy(target, {
    set(obj, key, value, receiver) {
      const validate = validators[key];
      if (validate && !validate(value)) {
        throw new ValidationError(key, value);
      }
      return Reflect.set(obj, key, value, receiver);
    },
    deleteProperty(obj, key) {
      if (key in validators) {
        throw new ValidationError(key, undefined);
      }
      return Reflect.deleteProperty(obj, key);
    }
  });
}

const form = createValidated({}, {
  age: (v) => Number.isInteger(v) && v >= 0 && v <= 150,
  name: (v) => typeof v === 'string' && v.length > 0
});

form.age = 30;
console.log(form.age); // 30

为什么传 receiver 而不是 obj?因为当 target 上有访问器属性时,Reflect.set(obj, key, value, receiver) 会以 receiver 作为 this 调用 setter。对 Proxy 而言 receiver 就是代理本身,这保证了 getter/setter 内部再访问 this.other 时仍会走代理(否则会绕过拦截,是响应式框架里丢失依赖收集的经典原因)。若你确实想直接写 target,就写 Reflect.set(obj, key, value),但要清楚自己放弃了 receiver 语义。

4.1 什么时候真的需要返回 false

只有两种情况适合返回 false 而非抛错:

  1. 你明确希望非严格调用方静默失败(例如实现 Object.freeze 类似语义的只读视图),并且接受严格调用方拿到 TypeError。
  2. 你在透传某个可能失败的底层写入结果,比如 target 上的属性是不可写的,此时 Reflect.set 自然返回 false。

如果需要「只读视图」,更常见的做法是让 trap 返回 Reflect.set 的结果并把 target 冻结,或者直接抛 TypeError 给出可读信息:

const readonly = (target) => new Proxy(target, {
  set(obj, key) {
    throw new TypeError(`Cannot assign to read only property '${String(key)}'`);
  },
  deleteProperty(obj, key) {
    throw new TypeError(`Cannot delete property '${String(key)}' of readonly object`);
  }
});

const cfg = readonly({ debug: false });
try {
  cfg.debug = true;
} catch (e) {
  console.log(e.message); // Cannot assign to read only property 'debug'
}

4.2 跨严格/非严格统一行为的最简策略

需求 trap 内写法 非严格调用方 严格调用方 / 内置方法
校验失败要报错 throw new ValidationError() 抛错 抛错
拒绝写入但保持静默 return false 静默失败 抛 TypeError: ... falsish
透传底层可写性 return Reflect.set(...) 视 target 而定 视 target 而定
只读视图 throw new TypeError() 抛错 抛错

多数业务代码应当落在第一行和第三行。第四行的区别只是错误类型与信息更贴近语言原生提示,便于调用方区分「语言层面不允许」和「业务校验不通过」。

五、报错信息对照与排查清单

不同引擎的措辞略有差异,但关键词一致,可以按下面的表快速定位。

引擎 典型报错 含义
V8(Node.js / Chrome) TypeError: 'set' on proxy: trap returned falsish for property 'age' set trap 返回了 falsy 值
V8 TypeError: 'set' on proxy: trap returned truish for property 'id' which exists in the proxy target as a non-writable and non-configurable data property with a different value than the one passed to the set trap 返回 true 但破坏了不变式
SpiderMonkey(Firefox) TypeError: proxy set trap returned falsy value for property 'age' 同上,措辞不同
JavaScriptCore(Safari) TypeError: Proxy set handler returned false for property '"age"' 同上

排查顺序建议:

  1. 看报错里的属性名,确认走的是哪个分支;
  2. 检查 handler 所有 return 路径,特别是 if 分支末尾缺 return true;
  3. 检查是否用了 set trap 之外的 trap(defineProperty、deleteProperty)返回了 falsy;
  4. 确认调用点是否由 Object.assign、push、Array.prototype.fill 等内置方法发起(它们使用严格语义);
  5. 若只有部分环境报错,先核对调用代码是否被转成 ESM(ES module 默认严格模式)。

六、FAQ

Q1:为什么同一份代码在 Node 脚本里正常,在打包后报错?

因为打包产物通常是 ES module 或经过 "use strict" 注入,赋值语句处于严格模式。set trap 的 falsy 返回值在严格模式下必然抛 TypeError。可以临时在赋值前打印 (function(){ return this; })() 判断当前是否严格模式(严格模式下是 undefined)。

Q2:return false 和 throw 该选哪个?

希望调用方感知失败并区分原因,选 throw;希望对齐语言「不可写属性」语义并可能被 Object.assign 之类的宽松路径静默跳过,选 return false(此时接受严格模式抛 TypeError)。两者不要混用:handler 里既 return false 又在别处 throw,会让错误类型难以预期。

Q3:target 是不可写属性,Reflect.set 返回 false 导致的报错算 bug 吗?

不算,这是符合规范的行为。若你不想在严格模式下抛错,要么把 target 上该属性改为可写,要么在 trap 里 throw 一个带业务信息的错误来替代语言原生错误,让调用方有明确的处理路径。

Q4:set trap 里返回 1、'ok' 这种 truthy 值可以吗?

可以,ToBoolean 之后为 true 即视为成功,随后仍要接受一致性检查。但强烈建议只返回布尔值,任何依赖隐式转换的写法都会给后来者埋坑,也没法通过代码评审。

Q5:为什么给 Proxy 包一层 Object.freeze 还是会报 falsish?

Object.freeze(proxy) 冻结的是 proxy 自身而非 target,且 freeze 走 [[PreventExtensions]] 与 [[DefineOwnProperty]],不会让 set trap 改变行为。要只读,请在 handler 里显式抛错或返回 false,并接受严格模式下的 TypeError。

Q6:Vue 3 的响应式系统为什么没被这个问题困扰?

它的 set trap 用 Reflect.set 的返回值作为 trap 返回值,并在需要拦截写入时抛 console.warn 加返回值处理,而不是简单地 return false。自己写响应式或表单状态层时照这个模式走最省心。

小结:set trap 的返回值被 [[Set]] 按 ToBoolean 解释,falsy 即「拒绝写入」;拒绝是否升级为 TypeError 取决于发起赋值的代码是否严格,以及是否经由内置方法(内置方法一律严格)。把「业务校验不通过」交给 throw,把「底层可写性」交给 Reflect.set 透传,只在确实需要语言原生「静默失败」语义时才 return false,就能绕开这类只在特定环境复现的报错。