从 Lodash hasIn 迁移到 Remeda hasProp:继承属性语义与深路径差异全解析
从 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,核心要记住两句话:
- 语义变严了:
hasIn认原型链(in运算符),hasProp只认 own 属性(Object.hasOwn),涉及继承属性判断的代码必须显式改写; - 能力变强了:
hasProp支持 data-first/data-last 双调用方式,并在字面量键场景下提供类型守卫收窄,这是 Lodash 版本不具备的编译期保障。
若原代码依赖深路径存在性判断,应改用 prop 逐层下钻的组合方案或 hasSubObject 整体匹配。迁移时建议对照仓库中的 迁移映射文档、hasProp 源码 及其 运行时测试 逐条验证行为边界,再用 类型测试 确认收窄结果符合预期,即可安全完成替换。