The short answer
Keep the values in a frozen object, and name the union of its values with a @typedef:
export const OrderStatus = Object.freeze({
Draft: "draft",
Paid: "paid",
Shipped: "shipped",
});
/** @typedef {(typeof OrderStatus)[keyof typeof OrderStatus]} OrderStatusValue */
/**
* @param {{ id: string, status: OrderStatusValue }} order
* @returns {boolean}
*/
export function canShip(order) {
return order.status === OrderStatus.Paid;
}
canShip({ id: "A-1001", status: OrderStatus.Paid });
canShip({ id: "A-1002", status: "draft" });export const OrderStatus = Object.freeze({
Draft: "draft",
Paid: "paid",
Shipped: "shipped",
});
export type OrderStatusValue = (typeof OrderStatus)[keyof typeof OrderStatus];
export function canShip(order: { id: string; status: OrderStatusValue }): boolean {
return order.status === OrderStatus.Paid;
}
canShip({ id: "A-1001", status: OrderStatus.Paid });
canShip({ id: "A-1002", status: "draft" });jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} Code can use OrderStatus.Paid or the plain string "draft"; both are members of OrderStatusValue, and anything else is an error.
Why it works
Object.freeze returns a read-only version of the object, and TypeScript keeps each value’s literal type ("draft", not string) because a frozen property can never change. typeof OrderStatus is the object’s type, keyof typeof OrderStatus is "Draft" | "Paid" | "Shipped", and indexing the object type with its keys gives the values: "draft" | "paid" | "shipped". The TypeScript handbook’s page on enums suggests the same pattern, an object with as const, for code that wants plain objects.
The same thing in TypeScript
The TypeScript equivalent tab is the same code with type OrderStatusValue = ... in place of the @typedef. A TypeScript enum would also create the object, but it is TypeScript-only syntax: in a .js file it is an error (TS8006).
What goes wrong
A misspelled status is caught at the call (TS2345), and the frozen object refuses changes (TS2540):
export const OrderStatus = Object.freeze({
Draft: "draft",
Paid: "paid",
Shipped: "shipped",
});
/** @typedef {(typeof OrderStatus)[keyof typeof OrderStatus]} OrderStatusValue */
/** @param {OrderStatusValue} status */
export function setStatus(status) {
return status;
}
setStatus("shiped");error TS2345: Argument of type '"shiped"' is not assignable to parameter of type 'OrderStatusValue'.
OrderStatus.Paid = "settled";error TS2540: Cannot assign to 'Paid' because it is a read-only property.jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} The @enum tag looks like the obvious tool, but @enum {string} types each member as string, so a misspelled status passes, and only unrelated types are caught:
/** @enum {string} */
export const OrderStatus = {
Draft: "draft",
Paid: "paid",
Shipped: "shipped",
};
/** @param {OrderStatus} status */
export function setStatus(status) {
return status;
}
setStatus("shiped");
/** @type {number} */
const count = setStatus(OrderStatus.Paid);error TS2322: Type 'string' is not assignable to type 'number'.jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} Gotchas
/** @type {const} */works too. A const cast on the object literal keeps the literals and makes the properties read-only to tsc;Object.freezealso makes them read-only at runtime.- TypeScript 7.0 changes
@enum. Its JavaScript support reads@enumdifferently, and the 7.0 page recommends this object pattern instead. - Freezing is shallow. Nested objects inside a frozen object can still change; freeze them too, or keep enum objects flat.
Questions
Can the @typedef have the same name as the object?
It can, since types and values live in separate namespaces, but a different name (OrderStatusValue) keeps it clear which one a line refers to, in JSDoc and in hovers.
How do I list every status at runtime, for a select menu?
Object.values(OrderStatus) returns the values, typed as an array of the union. That is something a union @typedef alone cannot do, and the reason to keep the object.
Does this work with numbers?
Yes. Object.freeze({ Low: 1, High: 2 }) keeps the number literals, and the @typedef becomes 1 | 2.
Sources
- Enums (objects vs enums) · TypeScript handbook
- JSDoc Reference (@enum) · TypeScript handbook
- Object.freeze() · MDN Web Docs
Last reviewed 21 October 2026.