Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions examples/frontend/src/github/githubUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,24 @@ export type ServerErrorStatus = Exclude<
ComputeRange<500>[number]
>;

function isMergeableObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
(value.constructor === Object || value.constructor === undefined)
);
}

export function deepMerge<T, U extends T>(target: T, source: U): U {
const returnType = (target || {}) as U;
const result = { ...target } as U;
for (const key in source) {
if (source[key] instanceof Object)
Object.assign(source[key], deepMerge(returnType[key], source[key]));
const sourceValue = source[key];
const targetValue = result[key];
result[key] =
isMergeableObject(targetValue) && isMergeableObject(sourceValue)
? (deepMerge(targetValue, sourceValue) as U[Extract<keyof U, string>])
: sourceValue;
}
Object.assign(returnType || {}, source);
return returnType;
return result;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2848,14 +2848,26 @@ describe("generateReactQueryComponents", () => {
ComputeRange<500>[number]
>;

function isMergeableObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
(value.constructor === Object || value.constructor === undefined)
);
}

export function deepMerge<T, U extends T>(target: T, source: U): U {
const returnType = (target || {}) as U;
const result = { ...target } as U;
for (const key in source) {
if (source[key] instanceof Object)
Object.assign(source[key], deepMerge(returnType[key], source[key]));
const sourceValue = source[key];
const targetValue = result[key];
result[key] =
isMergeableObject(targetValue) && isMergeableObject(sourceValue)
? (deepMerge(targetValue, sourceValue) as U[Extract<keyof U, string>])
: sourceValue;
}
Object.assign(returnType || {}, source);
return returnType;
return result;
}
"
`);
Expand Down
85 changes: 75 additions & 10 deletions plugins/typescript/src/templates/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,35 @@ import { it, describe, expect } from "vitest";

describe("deepMerge", () => {
/* Playground to craft `deepMerge` */
function isMergeableObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
(value.constructor === Object || value.constructor === undefined)
);
}

function deepMerge<T, U extends T>(target: T, source: U): U {
const returnType = (target || {}) as U;
const result = { ...target } as U;
for (const key in source) {
if (source[key] instanceof Object)
Object.assign(source[key], deepMerge(returnType[key], source[key]));
const sourceValue = source[key];
const targetValue = result[key];
result[key] =
isMergeableObject(targetValue) && isMergeableObject(sourceValue)
? (deepMerge(targetValue, sourceValue) as U[Extract<keyof U, string>])
: sourceValue;
}
Object.assign(returnType || {}, source);
return returnType;
return result;
}
/* End of playground */

it("should merge two objects", () => {
type fetcherOptions = {
headers: { "x-custom"?: boolean; authorization: string };
queryParams?: { media?: string };
};
type fetcherOptions = {
headers: { "x-custom"?: boolean; authorization: string };
queryParams?: { media?: string };
};

it("should merge two objects", () => {
const a: fetcherOptions = {
headers: { "x-custom": true, authorization: "nope!" },
queryParams: { media: "vite testing" },
Expand All @@ -33,4 +45,57 @@ describe("deepMerge", () => {
queryParams: { media: "vite testing" },
});
});

type variables = {
headers?: { "x-custom"?: boolean; authorization?: string };
body?: Record<string, unknown>;
};

it("should not mutate the target", () => {
const target: variables = {
headers: { "x-custom": true, authorization: "nope!" },
};

deepMerge(target, { headers: { authorization: "authorized!" } });

expect(target).toEqual({
headers: { "x-custom": true, authorization: "nope!" },
});
});

it("should not mutate the source", () => {
const target: variables = { headers: { "x-custom": true } };
const source: variables = { headers: { authorization: "authorized!" } };

deepMerge(target, source);

expect(source).toEqual({ headers: { authorization: "authorized!" } });
});

it("should replace arrays instead of merging them by index", () => {
expect(deepMerge({ ids: [1, 2, 3] }, { ids: [9] })).toEqual({ ids: [9] });
});

it("should not merge into non-plain objects", () => {
const body = new FormData();
body.append("file", "content");

expect(deepMerge({ body: new FormData() }, { body }).body).toBe(body);
});

it("should not leak state between calls sharing a target", () => {
const fetcherOptions: variables = {
headers: { authorization: "authorized!" },
};

const first = deepMerge(fetcherOptions, {
body: { title: "first", archivedAt: "2026-01-01" },
});
const second = deepMerge(fetcherOptions, {
body: { title: "second" },
});

expect(first.body).toEqual({ title: "first", archivedAt: "2026-01-01" });
expect(second.body).toEqual({ title: "second" });
});
});
22 changes: 17 additions & 5 deletions plugins/typescript/src/templates/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,25 @@ export const getUtils = () =>
export type ${clientErrorStatus} = Exclude<ComputeRange<500>[number], ComputeRange<400>[number]>;
export type ${serverErrorStatus} = Exclude<ComputeRange<600>[number], ComputeRange<500>[number]>;

function isMergeableObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
(value.constructor === Object || value.constructor === undefined)
);
}

export function deepMerge<T, U extends T>(target: T, source: U): U {
const returnType = (target || {}) as U;
const result = { ...target } as U;
for (const key in source) {
if (source[key] instanceof Object)
Object.assign(source[key], deepMerge(returnType[key], source[key]));
const sourceValue = source[key];
const targetValue = result[key];
result[key] =
isMergeableObject(targetValue) && isMergeableObject(sourceValue)
? (deepMerge(targetValue, sourceValue) as U[Extract<keyof U, string>])
: sourceValue;
}
Object.assign(returnType || {}, source);
return returnType;
return result;
}
`;