Object helpers
Seven helpers for reshaping objects, including the two cases ?. and if (!value) get wrong.
<script setup lang="ts">
import { cleanObject, get, isEmpty, omit, pick } from '@shared/utils/object'
import { deepEqual } from '@shared/utils/deepEqual'
import { deepMerge } from '@shared/utils/deepMerge'
import { Input } from '@shared/components/ui/input'
import { Label } from '@shared/components/ui/label'
import { computed, ref } from 'vue'
const RESPONSE = {
data: {
items: [{ id: 41, author: { name: 'Amina Haddad' } }],
total: 128,
},
}
const USER = { id: 7, email: 'amina@example.com', password: 'secret', role: 'admin' }
const FILTERS = { q: '', page: 0, role: null, archived: false, tags: [] }
const path = ref('data.items[0].author.name')
const found = computed(() => get(RESPONSE, path.value, '(not found)'))
const EMPTINESS: Array<[string, unknown]> = [
['0', 0],
['false', false],
["''", ''],
['[]', []],
['{}', {}],
['null', null],
['new Date()', new Date()],
]
const merged = deepMerge({ ui: { layout: 'modern', density: 'default' } }, { ui: { density: 'compact' } })
const same = deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] })
</script>
<template>
<div class="flex flex-col gap-5">
<section class="flex flex-col gap-2">
<Label for="obj-path">get(response, path)</Label>
<Input id="obj-path" v-model="path" class="max-w-md" />
<p class="sa-text-sm text-muted-foreground">
→ <strong>{{ found }}</strong>
</p>
</section>
<section class="flex flex-col gap-2">
<span class="sa-text-sm font-semibold">pick and omit</span>
<p class="sa-text-sm text-muted-foreground">
<code>pick(user, ['id', 'email'])</code> → {{ JSON.stringify(pick(USER, ['id', 'email'])) }}
</p>
<p class="sa-text-sm text-muted-foreground">
<code>omit(user, ['password'])</code> → {{ JSON.stringify(omit(USER, ['password'])) }}
</p>
</section>
<section class="flex flex-col gap-2">
<span class="sa-text-sm font-semibold">cleanObject</span>
<p class="sa-text-sm text-muted-foreground">
from {{ JSON.stringify(FILTERS) }}
</p>
<p class="sa-text-sm">
→ <strong>{{ JSON.stringify(cleanObject(FILTERS)) }}</strong>
</p>
<p class="sa-text-xs text-muted-foreground">
<code>page: 0</code> and <code>archived: false</code> survive; the empty string and the null do not.
</p>
</section>
<section class="flex flex-col gap-2">
<span class="sa-text-sm font-semibold">isEmpty</span>
<div class="flex flex-wrap gap-x-4 gap-y-1 sa-text-sm text-muted-foreground">
<span v-for="[label, v] in EMPTINESS" :key="label">
<code>{{ label }}</code> → {{ isEmpty(v) }}
</span>
</div>
</section>
<section class="flex flex-col gap-2">
<span class="sa-text-sm font-semibold">deepMerge and deepEqual</span>
<p class="sa-text-sm text-muted-foreground">
{{ JSON.stringify(merged) }} · structurally equal: {{ same }}
</p>
</section>
</div>
</template>Import
import { cleanObject, get, isEmpty, omit, pick } from '@shared/utils/object'
import { deepEqual } from '@shared/utils/deepEqual'
import { deepMerge } from '@shared/utils/deepMerge'pick
function pick<T extends object, K extends keyof T>(source: T, keys: readonly K[]): Pick<T, K>pick(user, ['id', 'email'])Absent keys are not invented, so this never adds an undefined property for JSON.stringify to drop or a server to reject.
omit
function omit<T extends object, K extends keyof T>(source: T, keys: readonly K[]): Omit<T, K>omit(form, ['passwordConfirmation'])get
function get<T = unknown>(source: unknown, path: string): T | undefined
function get<T>(source: unknown, path: string, fallback: T): TReads a nested value by path without throwing on a missing link. Both notations work:
get(response, 'data.items[0].id')
get(response, 'data.items.0.id')
get(response, 'data.total', 0) // 0 when absentThis is for when the path is data — a column definition, a runtime sort key, a config string — where optional chaining cannot reach because the path is not known when you write the code.
cleanObject
function cleanObject<T extends object>(source: T, options?: CleanObjectOptions): Partial<T>
interface CleanObjectOptions {
removeEmptyStrings?: boolean
removeEmptyCollections?: boolean
}cleanObject({ q: '', page: 0, role: null }) // { page: 0 }Drops null and undefined, and by default '' too — an empty filter input is not a filter, so a filter bar sends ?status=active rather than ?q=&role=null.
0 and false are kept. Dropping them would lose page=0 and archived=false, which is the bug this avoids.
removeEmptyStrings defaults to true, removeEmptyCollections to false.
isEmpty
function isEmpty(value: unknown): boolean| Value | isEmpty |
|---|---|
null, undefined | true |
'' | true |
[], {} | true |
an empty Map or Set | true |
0, false, NaN | false |
any Date | false |
isEmpty(0) // false
isEmpty({}) // trueThe last two rows are the point. if (!value) treats 0 and false as absent, which is how a quantity of zero disappears from a form.
deepEqual
function deepEqual(a: unknown, b: unknown): booleanimport { deepEqual } from '@shared/utils/deepEqual'Structural comparison. useFormDirty is built on it.
deepMerge
function deepMerge<T extends object>(target: T, source: DeepPartial<NoInfer<T>>): Timport { deepMerge } from '@shared/utils/deepMerge'Merges nested objects; arrays are replaced, not concatenated. It is what useConfig's mergeConfig and usePageConfig use.
Related
- Array helpers — the same ideas, one level out.
useConfig—deepMergein production.useCrud— wherecleanObjectbelongs, on the filter bar.