refactor: replace JSON.parse with parseJsonWithSchema (@dev-mohit06) (#6207)

## Description
Replaces raw JSON parsing with schema-based validation across frontend
TypeScript files to improve type safety and error handling.

### Scope of Changes
- Updated JSON parsing in:
  - `account.ts`
  - `import-export-settings.ts`
  - `analytics-controller.ts`
  - `local-storage-with-schema.ts`
  - `url-handler.ts`
  - `commandline/lists.ts`
  - `test/wikipedia.ts`

- Added schema in `test/custom-text.ts`:
  ```typescript
  export const customTextDataSchema = z.object({
    text: z.array(z.string()),
    mode: CustomTextModeSchema,
    limit: z.object({ 
      value: z.number(), 
      mode: CustomTextLimitModeSchema 
    }),
    pipeDelimiter: z.boolean(),
  });
  ```

### Benefits
- Enhanced runtime type safety
- More robust error handling
- Consistent JSON parsing approach

### Checks
- [x] Follows Conventional Commits
- [x] Includes GitHub username
- [ ] Adding quotes? (N/A)
- [ ] Adding language/theme? (N/A)

---------

Co-authored-by: Miodec <jack@monkeytype.com>
This commit is contained in:
Mohit Paddhariya 2025-02-04 04:15:57 +05:30 committed by GitHub
parent 86cb17be82
commit 3510ea9760
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 92 additions and 35 deletions

View file

@ -108,6 +108,7 @@ import * as FPSCounter from "../elements/fps-counter";
import { migrateConfig } from "../utils/config";
import { PartialConfigSchema } from "@monkeytype/contracts/schemas/configs";
import { Command, CommandsSubgroup } from "./types";
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
const layoutsPromise = JSONData.getLayoutsList();
layoutsPromise
@ -362,8 +363,9 @@ export const commands: CommandsSubgroup = {
exec: async ({ input }): Promise<void> => {
if (input === undefined || input === "") return;
try {
const parsedConfig = PartialConfigSchema.strip().parse(
JSON.parse(input)
const parsedConfig = parseJsonWithSchema(
input,
PartialConfigSchema.strip()
);
await UpdateConfig.apply(migrateConfig(parsedConfig));
UpdateConfig.saveFullConfigToLocalStorage();

View file

@ -6,13 +6,17 @@ import {
} from "firebase/analytics";
import { app as firebaseApp } from "../firebase";
import { createErrorMessage } from "../utils/misc";
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
import { z } from "zod";
let analytics: AnalyticsType;
type AcceptedCookies = {
security: boolean;
analytics: boolean;
};
const AcceptedCookiesSchema = z.object({
security: z.boolean(),
analytics: z.boolean(),
});
type AcceptedCookies = z.infer<typeof AcceptedCookiesSchema>;
export async function log(
eventName: string,
@ -26,9 +30,14 @@ export async function log(
}
const lsString = localStorage.getItem("acceptedCookies");
let acceptedCookies;
let acceptedCookies: AcceptedCookies | null;
if (lsString !== undefined && lsString !== null && lsString !== "") {
acceptedCookies = JSON.parse(lsString) as AcceptedCookies;
try {
acceptedCookies = parseJsonWithSchema(lsString, AcceptedCookiesSchema);
} catch (e) {
console.error("Failed to parse accepted cookies:", e);
acceptedCookies = null;
}
} else {
acceptedCookies = null;
}

View file

@ -5,6 +5,8 @@ import { isAuthenticated } from "../firebase";
import * as Notifications from "../elements/notifications";
import * as EditResultTagsModal from "../modals/edit-result-tags";
import * as AddFilterPresetModal from "../modals/new-filter-preset";
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
import { z } from "zod";
const accountPage = document.querySelector("#pageAccount") as HTMLElement;
@ -36,12 +38,15 @@ $(accountPage).on("click", ".editProfileButton", () => {
EditProfileModal.show();
});
const TagsArraySchema = z.array(z.string());
$(accountPage).on("click", ".group.history .resultEditTagsButton", (e) => {
const resultid = $(e.target).attr("data-result-id");
const tags = $(e.target).attr("data-tags");
EditResultTagsModal.show(
resultid ?? "",
JSON.parse(tags ?? "[]") as string[],
parseJsonWithSchema(tags ?? "[]", TagsArraySchema),
"accountPage"
);
});

View file

@ -3,6 +3,7 @@ import * as UpdateConfig from "../config";
import * as Notifications from "../elements/notifications";
import AnimatedModal from "../utils/animated-modal";
import { migrateConfig } from "../utils/config";
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
type State = {
mode: "import" | "export";
@ -47,8 +48,9 @@ const modal = new AnimatedModal({
return;
}
try {
const parsedConfig = PartialConfigSchema.strip().parse(
JSON.parse(state.value)
const parsedConfig = parseJsonWithSchema(
state.value,
PartialConfigSchema.strip()
);
await UpdateConfig.apply(migrateConfig(parsedConfig));
} catch (e) {

View file

@ -29,7 +29,7 @@ const customTextLongLS = new LocalStorageWithSchema({
fallback: {},
});
const CustomTextSettingsSchema = z.object({
export const CustomTextSettingsSchema = z.object({
text: z.array(z.string()),
mode: CustomTextModeSchema,
limit: z.object({ value: z.number(), mode: CustomTextLimitModeSchema }),

View file

@ -2,6 +2,8 @@ import * as Loader from "../elements/loader";
import * as Misc from "../utils/misc";
import * as Strings from "../utils/strings";
import * as JSONData from "../utils/json-data";
import { z } from "zod";
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
export async function getTLD(
languageGroup: JSONData.LanguageGroup
@ -241,6 +243,18 @@ type SectionObject = {
author: string;
};
// Section Schema
const SectionSchema = z.object({
query: z.object({
pages: z.record(
z.string(),
z.object({
extract: z.string(),
})
),
}),
});
export async function getSection(language: string): Promise<JSONData.Section> {
// console.log("Getting section");
Loader.show();
@ -285,10 +299,17 @@ export async function getSection(language: string): Promise<JSONData.Section> {
sectionReq.onload = (): void => {
if (sectionReq.readyState === 4) {
if (sectionReq.status === 200) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
let sectionText = JSON.parse(sectionReq.responseText).query.pages[
pageid.toString()
].extract as string;
const parsedResponse = parseJsonWithSchema(
sectionReq.responseText,
SectionSchema
);
const page = parsedResponse.query.pages[pageid.toString()];
if (!page) {
Loader.hide();
rej("Page not found");
return;
}
let sectionText = page.extract;
// Converting to one paragraph
sectionText = sectionText.replace(/<\/p><p>+/g, " ");

View file

@ -11,7 +11,11 @@ import * as Loader from "../elements/loader";
import * as AccountButton from "../elements/account-button";
import { restart as restartTest } from "../test/test-logic";
import * as ChallengeController from "../controllers/challenge-controller";
import { Mode, Mode2 } from "@monkeytype/contracts/schemas/shared";
import {
DifficultySchema,
Mode2Schema,
ModeSchema,
} from "@monkeytype/contracts/schemas/shared";
import {
CustomBackgroundFilter,
CustomBackgroundFilterSchema,
@ -19,7 +23,6 @@ import {
CustomBackgroundSizeSchema,
CustomThemeColors,
CustomThemeColorsSchema,
Difficulty,
} from "@monkeytype/contracts/schemas/configs";
import { z } from "zod";
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
@ -129,24 +132,35 @@ export function loadCustomThemeFromUrl(getOverride?: string): void {
}
}
type SharedTestSettings = [
Mode | null,
Mode2<Mode> | null,
CustomText.CustomTextData | null,
boolean | null,
boolean | null,
string | null,
Difficulty | null,
string | null
];
const TestSettingsSchema = z.tuple([
ModeSchema.nullable(),
Mode2Schema.nullable(),
CustomText.CustomTextSettingsSchema.nullable(),
z.boolean().nullable(), //punctuation
z.boolean().nullable(), //numbers
z.string().nullable(), //language
DifficultySchema.nullable(),
z.string().nullable(), //funbox
]);
type SharedTestSettings = z.infer<typeof TestSettingsSchema>;
export function loadTestSettingsFromUrl(getOverride?: string): void {
const getValue = Misc.findGetParameter("testSettings", getOverride);
if (getValue === null) return;
const de = JSON.parse(
decompressFromURI(getValue) ?? ""
) as SharedTestSettings;
let de: SharedTestSettings;
try {
const decompressed = decompressFromURI(getValue) ?? "";
de = parseJsonWithSchema(decompressed, TestSettingsSchema);
} catch (e) {
console.error("Failed to parse test settings:", e);
Notifications.add(
"Failed to load test settings from URL: " + (e as Error).message,
0
);
return;
}
const applied: Record<string, string> = {};

View file

@ -1,4 +1,4 @@
import { ZodError, ZodIssue, ZodSchema } from "zod";
import { z, ZodError, ZodIssue } from "zod";
/**
* Parse a JSON string into an object and validate it against a schema
@ -6,11 +6,15 @@ import { ZodError, ZodIssue, ZodSchema } from "zod";
* @param schema Zod schema to validate the JSON against
* @returns The parsed JSON object
*/
export function parseWithSchema<T>(json: string, schema: ZodSchema<T>): T {
export function parseWithSchema<T extends z.ZodTypeAny>(
json: string,
schema: T
): z.infer<T> {
try {
const jsonParsed = JSON.parse(json) as unknown;
const zodParsed = schema.parse(jsonParsed);
return zodParsed;
// hits is fine to ignore
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return schema.parse(jsonParsed) as z.infer<T>;
} catch (error) {
if (error instanceof ZodError) {
throw new Error(error.issues.map(prettyErrorMessage).join("\n"));