The short answer
Store the parsed value as unknown, check the parts you need, and return the typed object only after the checks pass:
/**
* @typedef {object} Settings
* @property {"light" | "dark"} theme
* @property {number} fontSize
*/
/**
* @param {string} text
* @returns {Settings}
*/
export function parseSettings(text) {
/** @type {unknown} */
const data = JSON.parse(text);
if (
typeof data === "object" &&
data !== null &&
"theme" in data &&
(data.theme === "light" || data.theme === "dark") &&
"fontSize" in data &&
typeof data.fontSize === "number"
) {
return { theme: data.theme, fontSize: data.fontSize };
}
throw new TypeError("settings.json does not hold settings");
}export interface Settings {
theme: "light" | "dark";
fontSize: number;
}
export function parseSettings(text: string): Settings {
const data: unknown = JSON.parse(text);
if (
typeof data === "object" &&
data !== null &&
"theme" in data &&
(data.theme === "light" || data.theme === "dark") &&
"fontSize" in data &&
typeof data.fontSize === "number"
) {
return { theme: data.theme, fontSize: data.fontSize };
}
throw new TypeError("settings.json does not hold settings");
}jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} Inside the if, each check narrows data a little further: an object, not null, with a theme that is "light" or "dark" and a numeric fontSize. By the return, tsc knows enough to accept { theme: data.theme, fontSize: data.fontSize } as Settings.
Why it works
unknown is the type of a value you know nothing about: you can pass it around, but you cannot read its properties until you check. Each typeof, !== null, in and equality test narrows it, and since TypeScript 4.9 an in check also tells tsc the property exists. The function’s @returns {Settings} is then a fact about the data, not a hope.
When the JSON comes from a source you control and trust, a cast is shorter. It gives the value a type and checks nothing:
/** @typedef {{ theme: "light" | "dark", fontSize: number }} Settings */
/**
* @param {string} text
* @returns {Settings}
*/
export function parseSettings(text) {
/** @type {Settings} */
const settings = JSON.parse(text);
return settings;
}
const settings = parseSettings('{"theme":"dark","fontSize":"14px"}');
console.log(settings.fontSize.toFixed(0));jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} That example passes tsc, and it would throw at runtime: the file says "14px", so fontSize.toFixed is not a function. A cast moves the risk to the data.
The same thing in TypeScript
The TypeScript equivalent tab writes const data: unknown = JSON.parse(text); the checks are the same JavaScript. The cast version would be JSON.parse(text) as Settings.
What goes wrong
Reading a property of an unknown value before checking it is TS18046, which is the point: tsc makes you look before you use it.
/** @param {string} text */
export function readTheme(text) {
/** @type {unknown} */
const data = JSON.parse(text);
return data.theme;error TS18046: 'data' is of type 'unknown'.
}jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} Gotchas
anyspreads. LeaveJSON.parse(text)untyped and everything read from it isanytoo, through every function it reaches; a cast or anunknownstops that at the source.- A
@typeon a variable is a cast foranyvalues./** @type {Settings} */ const settings = JSON.parse(text)is accepted becauseanyfits every type; the same tag on a real value of the wrong type is an error. - Dates and maps do not survive JSON. A
Datecomes back as a string. Type the parsed shape as it is in the file, then convert.
Questions
Why does JSON.parse return any and not unknown?
That is how TypeScript's library declares it, in TypeScript 5.9 as before. Annotating the result as unknown yourself gets the safer behavior.
Is there a way to check the data without writing the checks by hand?
A schema library that checks data at runtime and also exposes the matching type does both jobs from one definition. The guide on runtime types and annotations shows the hand-written version of the same idea.
Sources
- JSON.parse() · MDN Web Docs
- TypeScript 4.9 release notes (narrowing with the in operator) · TypeScript handbook
- JSDoc Reference (@type and casts) · TypeScript handbook
Last reviewed 21 October 2026.