The short answer
Put @satisfies {AppConfig} on the object instead of @type {AppConfig}:
/** @typedef {"debug" | "info" | "warn" | "error"} LogLevel */
/**
* @typedef {object} AppConfig
* @property {LogLevel} logLevel
* @property {Record<string, { path: string, methods: string[] }>} routes
*/
/** @satisfies {AppConfig} */
export const config = {
logLevel: "warn",
routes: {
orders: { path: "/api/orders", methods: ["GET", "POST"] },
stock: { path: "/api/stock", methods: ["GET"] },
},
};
/** @type {"warn"} */
const level = config.logLevel;
console.log(level, config.routes.orders.path, config.routes.stock.methods.length);type LogLevel = "debug" | "info" | "warn" | "error";
interface AppConfig {
logLevel: LogLevel;
routes: Record<string, { path: string; methods: string[] }>;
}
export const config = {
logLevel: "warn",
routes: {
orders: { path: "/api/orders", methods: ["GET", "POST"] },
stock: { path: "/api/stock", methods: ["GET"] },
},
} satisfies AppConfig;
const level: "warn" = config.logLevel;
console.log(level, config.routes.orders.path, config.routes.stock.methods.length);jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} tsc checks the object against AppConfig where you write it, and the variable keeps the object’s own type: config.logLevel is "warn" (so it can be assigned to a "warn" variable), and config.routes has exactly the keys orders and stock.
Why it works
@type and @satisfies both check the value, but they give the variable different types. With @type {AppConfig} the variable is an AppConfig: any LogLevel, any route name. With @satisfies {AppConfig} the variable has the type inferred from the object literal, with AppConfig guiding the inference (which is what keeps "warn" a literal instead of widening it to string), and the object is checked to fit AppConfig. A Record with string keys is the clearest case: the declared type allows every key, while the object only has two.
The same thing in TypeScript
The TypeScript equivalent tab ends the object with satisfies AppConfig, the operator TypeScript 4.9 added. JavaScript has no such operator, so the JSDoc tag (TypeScript 5.0 and later) does the same job from a comment.
What goes wrong
Both kinds of mistake are still caught. A wrong value fails on its own line (TS2322), and because the keys are known, a misspelled route name fails where it is used (TS2551):
/** @typedef {"debug" | "info" | "warn" | "error"} LogLevel */
/**
* @typedef {object} AppConfig
* @property {LogLevel} logLevel
* @property {Record<string, { path: string, methods: string[] }>} routes
*/
/** @satisfies {AppConfig} */
export const config = {
logLevel: "verbose",error TS2322: Type '"verbose"' is not assignable to type 'LogLevel'.
routes: {
orders: { path: "/api/orders", methods: ["GET", "POST"] },
},
};
console.log(config.routes.ordrs.path);error TS2551: Property 'ordrs' does not exist on type '{ orders: { path: string; methods: string[]; }; }'. Did you mean 'orders'?jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} With @type instead, the same object loses its literal value: config.logLevel is the whole LogLevel union, so the "warn" variable no longer accepts it. And config.routes.ordrs is accepted, because a Record<string, …> has every key:
/**
* @typedef {object} AppConfig
* @property {"debug" | "info" | "warn" | "error"} logLevel
* @property {Record<string, { path: string, methods: string[] }>} routes
*/
/** @type {AppConfig} */
export const config = {
logLevel: "warn",
routes: {
orders: { path: "/api/orders", methods: ["GET", "POST"] },
},
};
/** @type {"warn"} */
const level = config.logLevel;error TS2322: Type '"debug" | "info" | "warn" | "error"' is not assignable to type '"warn"'.
Type '"debug"' is not assignable to type '"warn"'.
console.log(level, config.routes.ordrs.path);jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} Gotchas
- Excess properties are still errors.
@satisfiesruns the same extra-property check as@type, so a misspelled key inside the object is reported where you write it. - Narrow types are hard to change.
config.logLevelis"warn", soconfig.logLevel = "error"later is TS2322. Use@typefor a config your code updates, and@satisfiesfor one it only reads. - Put the tag right above the object.
@satisfiesapplies to the expression it annotates; on aconstdeclaration that is the initializer.
Questions
When is @type the better choice?
When the variable will be reassigned or mutated with other values the type allows. @type gives the variable the declared type; @satisfies gives it the object's own, narrower type, which only fits values like it.
Does @satisfies work on things other than objects?
Yes: on arrays, functions and any expression. The Express recipe uses it on a request handler, where @type would also fix the function's return type.
Sources
- TypeScript 5.0 release notes (@satisfies support in JSDoc) · TypeScript handbook
- TypeScript 4.9 release notes (the satisfies operator) · TypeScript handbook
Last reviewed 21 October 2026.