Recipe · Objects and data

How do I check a config object without losing its literal types?

  • Node.js (ES modules)
  • @satisfies
  • Config objects
  • TypeScript 5.0
  • Checked with TypeScript 5.9

The problem When I annotate my config object with @type, I lose the exact keys and values: every route name is allowed and logLevel is just a union. How do I check the object and keep what it actually holds?

The short answer

Put @satisfies {AppConfig} on the object instead of @type {AppConfig}:

config.js config.ts TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. No errors. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. No errors. checked 21 October 2026
/** @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);
A config object checked with @satisfies keeps its own keys and literals.
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):

config.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: config.js(12,3): error TS2322: Type '"verbose"' is not assignable to type 'LogLevel'. config.js(19,27): error TS2551: Property 'ordrs' does not exist on type '{ orders: { path: string; methods: string[]; }; }'. Did you mean 'orders'? 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. config.js(12,3): error TS2322: Type '"verbose"' is not assignable to type 'LogLevel'. config.js(19,27): error TS2551: Property 'ordrs' does not exist on type '{ orders: { path: string; methods: string[]; }; }'. Did you mean 'orders'? 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. config.js(12,3): error TS2322: Type '"verbose"' is not assignable to type 'LogLevel'. config.js(19,27): error TS2551: Property 'ordrs' does not exist on type '{ orders: { path: string; methods: string[]; }; }'. Did you mean 'orders'? checked 21 October 2026
/** @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'?
A wrong value fails where it is written, and a misspelled route is caught on use.
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:

config.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: config.js(17,7): error TS2322: Type '"debug" | "info" | "warn" | "error"' is not assignable to type '"warn"'. Type '"debug"' is not assignable to type '"warn"'. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. config.js(17,7): error TS2322: Type '"debug" | "info" | "warn" | "error"' is not assignable to type '"warn"'. Type '"debug"' is not assignable to type '"warn"'. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. config.js(17,7): error TS2322: Type '"debug" | "error" | "info" | "warn"' is not assignable to type '"warn"'. Type '"debug"' is not assignable to type '"warn"'. checked 21 October 2026
/**
 * @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);
The same object with @type: every key of a Record is allowed.
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. @satisfies runs 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.logLevel is "warn", so config.logLevel = "error" later is TS2322. Use @type for a config your code updates, and @satisfies for one it only reads.
  • Put the tag right above the object. @satisfies applies to the expression it annotates; on a const declaration 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

Last reviewed 21 October 2026.