从 Lodash hasIn 迁移到 Remeda hasProp:继承属性语义与深路径差异全解析

原创2026-10-12 11:17:091,411 阅读
文章标签:开发工具

从 Lodash hasIn 迁移到 Remeda hasProp:继承属性语义与深路径差异全解析

Lodash 的 hasIn 与 Remeda 的 hasProp 表面上都是"判断对象上是否存在某个属性",但两者在属性检查语义(own 属性 vs 原型链)和路径能力(深路径 vs 单键)上存在本质差异。本文以仓库中的迁移映射文档为主线,结合 Remeda 的 hasProp 源码与测试用例,完整讲解两个 API 的差异点、hasProp 的类型守卫能力,以及从 Lodash 迁移时的替代方案与注意事项。读完你可以准确判断 hasProp 的适用边界,并写出安全、类型完备的迁移代码。

迁移背景:hasIn 在 Remeda 中的对应物

Remeda 官方文档将各主流工具库(Lodash、Ramda、just)的函数逐一映射到 Remeda 的对应实现,这些映射关系以 Markdown 文件的形式维护在 packages/docs/src/content/mapping/ 目录下,其中 Lodash 的映射位于 packages/docs/src/content/mapping/lodash/。

hasIn 的映射文档 hasIn.md 的 frontmatter 明确了两件事:

  • category: Object——沿用 Lodash 对该函数的分类(对象操作);
  • remeda: hasProp——指向 Remeda 中的对应函数 hasProp,该引用会通过 mapping 集合配置 中定义的 remeda 字段与 functions 集合 关联起来。

在文档站点的"迁移指南"页面(由 迁移页面模板 渲染),这类映射会被按分类分组、排序后以函数卡片的形式呈现,帮助开发者逐函数对照迁移。hasIn → hasProp 正是其中一个典型条目,但它绝不是"改名替换"那么简单,两者在行为语义上有两个关键差异。

差异一:in 运算符 vs Object.hasOwn——继承属性怎么算

行为差异

Lodash 的 hasIn 基于 JavaScript 的 in 运算符 实现,而 in 运算符会沿着原型链查找属性:只要属性存在于对象自身或其原型链上的任何一环,就返回 true。因此 hasIn 对继承属性同样返回 true。

Remeda 的 hasProp 则基于 Object.hasOwn 实现,只检查对象自身的属性(own property),原型链上的继承属性一律被忽略。

迁移映射文档用两个代码块对比例证了这一差异:

Own properties(自有属性,两者行为一致)

// Lodash
hasIn(obj, "a");

// Remeda
hasProp(obj, "a");

Inherited properties(继承属性,行为分叉)

// Lodash
hasIn({}, "toString"); //=> true

// Remeda
hasProp({}, "toString"); //=> false

{} 自身并不拥有 toString,但它继承自 Object.prototype,所以 Lodash 返回 true;而 Remeda 只认 own 属性,返回 false。这是从 Lodash 迁移过来最容易踩到的行为差异:凡是依赖 hasIn 判断"原型方法/继承字段是否存在"的代码,迁移到 hasProp 后语义都会收紧。

源码佐证

hasProp 的实现位于 hasProp.ts,核心逻辑只有一行:

function hasPropImplementation<T extends object, Key extends KeysOfUnion<T>>(
  data: T,
  key: Key,
): data is HasProp<T, Key> {
  return Object.hasOwn(data, key);
}

运行时测试 hasProp.test.ts 用多组用例印证了这一语义边界:

// 继承的原型属性(toString)返回 false
expect(hasProp({} as Record<string, unknown>, "toString")).toBe(false);

// 自定义原型上的属性同样返回 false
expect(
  hasProp(Object.create({ inherited: 1 }), "inherited"),
).toBe(false);

// 但 null 原型对象上的 own 属性可以正常命中
expect(
  hasProp(Object.assign(Object.create(null), { a: 1 }), "a"),
).toBe(true);

需要特别指出:own 属性、值为 undefined 时,hasProp 返回 true(测试 returns true for an own property whose value is undefined)。也就是说 hasProp 判断的是"键是否存在",而不是"值是否非空",这对应 Object.hasOwn 的语义——只要属性名存在,无论值是什么都算命中。

差异二:深路径 vs 单键——hasProp 只认一个 key

映射文档列出的第二个差异是路径能力:

Lodash 的 hasIn 接受深路径("a.b.c" 或 ["a", "b", "c"])。Remeda 的 hasProp 只检查单个 key。

// Lodash:字符串路径与数组路径都支持
hasIn(obj, "a.b.c");
hasIn(obj, ["a", "b", "c"]);

// Remeda:hasProp 只接受一个键
hasProp(obj, "a");

这一点从 hasProp.ts 的重载签名也可以直接看出——hasProp(data, key) 的参数只有一个 key,不存在变参或路径解析逻辑。同样的约束也体现在 has.md 映射文档(Lodash has 对应 Remeda hasProp)中,即 hasProp 对路径的支持被刻意限制为单层。

迁移替代方案

如果业务上确实需要深路径存在性判断,Remeda 提供了两个组合思路:

方案一:hasProp + prop 逐层下钻

Remeda 的 prop 用于读取属性值,它通过变参数组支持嵌套路径(prop(data, "a", "b", "c")),文档注释中明确写着"要判断键是否存在,请使用 hasProp"。可以据此手写一个逐层检查的辅助函数:

import { hasProp, prop } from "remeda";

function hasDeepPath(data: object, path: readonly PropertyKey[]): boolean {
  let current: unknown = data;
  for (const key of path) {
    if (current === null || current === undefined || !hasProp(current, key)) {
      return false;
    }
    current = prop(current, key);
  }
  return true;
}

hasDeepPath({ a: { b: { c: 1 } } }, ["a", "b", "c"]); //=> true
hasDeepPath({ a: { b: {} } }, ["a", "b", "c"]);      //=> false

方案二:hasSubObject 做整体子对象匹配

如果目标是判断"对象是否包含某个子对象的所有键值",可以直接使用 hasSubObject。它在内部同样以 Object.hasOwn 逐键检查,再用 isDeepEqual 比对值:

import { hasSubObject } from "remeda";

hasSubObject({ a: 1, b: 2, c: 3 }, { a: 1, c: 3 }); //=> true

注意 hasSubObject 的语义是"键存在且值相等",比单纯的存在性判断更强,适合校验配置片段等场景。

迁移后得到的增量能力:hasProp 的类型守卫

迁移到 hasProp 不只是"换个函数名",它比 Lodash 的 hasIn(普通布尔判断)多了 TypeScript 层面的**类型守卫(type guard)**能力。从 hasProp.ts 的签名可见:

export function hasProp<T extends object, Key extends KeysOfUnion<T>>(
  data: NonArray<T>,
  key: IsBounded<Key> extends true ? Key : never,
): data is HasProp<T, Key>;

在条件分支中通过守卫后,类型会被收窄。以 类型测试 中的典型场景为例:

// 可选属性被收窄为必选
const data = {} as { a?: string; b: number };
if (hasProp(data, "a")) {
  // data 被收窄为 { a: string; b: number }
}

// 联合类型中不含该键的成员被剔除
const union = {} as { a: number } | { b: string };
if (hasProp(union, "a")) {
  // union 被收窄为 { a: number }
} else {
  // union 被收窄为 { b: string }
}

在数据流式处理中这一能力尤其有用,配合 data-last 形式可以精准过滤并推导类型:

import { filter, pipe, hasProp } from "remeda";

const data = [] as { a?: number; b?: number }[];

// filter 后的类型被推导为 { a: number; b?: number }[]
const narrowed = filter(data, hasProp("a"));

// 管道写法等价
pipe(data, filter(hasProp("a")));

两套重载的分流逻辑

hasProp 的类型层实现相当考究,源码注释揭示了其设计动机:

  • **有界字面量键("a"、"a" | "b" 等)**走类型守卫重载,触发收窄;
  • **无界键(string、模板字符串类型如 `prefix_${string}`)**回退到 boolean 返回重载,避免守卫把 else 分支坍缩成 never;
  • 数组被 NonArray<T> 过滤掉,统一走布尔重载——因为数组的 keyof 包含 push、pop 等原型方法,这些在运行时并非 own 属性,若走守卫分支会把 else 分支错误地坍缩为 never。类型测试 falls back to boolean for array inputs 正是验证这一行为;数组元素的收窄改用 hasAtLeast、isArray 完成。

此外,hasProp 还支持 Symbol 键与数字键的守卫收窄(见 narrows symbol keys、narrows number keys 测试),并能在收窄时保留 readonly 修饰符。

已知的类型层限制

类型测试的 known limitations 小节记录了一个重要边界:对于 Map、Set 这类内置类型,其 keyof 在类型层面包含原型方法(如 Map.prototype.get、Set.prototype.has),守卫分支会把 else 分支收窄为 never;但运行时 Object.hasOwn 返回 false。源码注释指出这是 TypeScript 无法区分 own 与继承属性的固有限制(microsoft/TypeScript#58877 已关闭为"不计划支持"),数组被特殊处理绕开,但 Map/Set 等类型暂时无法在类型层面消除该偏差。因此对 Map/Set 实例调用 hasProp 时,应知晓这一已知局限,不要依赖守卫结果做运行时判断。

迁移速查清单

场景 Lodash hasIn Remeda hasProp
判断 own 属性 hasIn(obj, "a") hasProp(obj, "a")
判断继承属性 hasIn({}, "toString") → true hasProp({}, "toString") → false
值为 undefined 的 own 属性 true true
深路径 "a.b.c" / ["a","b","c"] 直接支持 不支持,需用 prop 逐层下钻或改用 hasSubObject
Symbol / 数字键 支持 支持
类型收窄 无(普通布尔) 有(字面量键场景下为 type guard)
调用风格 单一形式 data-first / data-last(purry 双签名)

hasProp 通过 purry 同时暴露 data-first(hasProp(data, key))与 data-last(hasProp(key)(data) / pipe(data, hasProp(key)))两种签名,前者适合直接调用,后者天然融入 Remeda 的管道风格,这在迁移改造中通常反而是加分项。

总结

从 Lodash hasIn 迁移到 Remeda hasProp,核心要记住两句话:

  1. 语义变严了:hasIn 认原型链(in 运算符),hasProp 只认 own 属性(Object.hasOwn),涉及继承属性判断的代码必须显式改写;
  2. 能力变强了:hasProp 支持 data-first/data-last 双调用方式,并在字面量键场景下提供类型守卫收窄,这是 Lodash 版本不具备的编译期保障。

若原代码依赖深路径存在性判断,应改用 prop 逐层下钻的组合方案或 hasSubObject 整体匹配。迁移时建议对照仓库中的 迁移映射文档、hasProp 源码 及其 运行时测试 逐条验证行为边界,再用 类型测试 确认收窄结果符合预期,即可安全完成替换。

登录后查看全文
remeda