The short answer
Name the keys as a union of string literals, then type the object as Record<OrderStatus, string>. Use Partial<Record<…>> when only some keys need a value:
/** @typedef {"draft" | "paid" | "shipped" | "cancelled"} OrderStatus */
/** @type {Record<OrderStatus, string>} */
const LABELS = {
draft: "Draft",
paid: "Paid",
shipped: "On its way",
cancelled: "Cancelled",
};
/** @type {Partial<Record<OrderStatus, string>>} */
const ICONS = { shipped: "truck", cancelled: "cross" };
/**
* @param {OrderStatus} status
* @returns {string}
*/
export function badge(status) {
const icon = ICONS[status];
return icon ? `${icon} ${LABELS[status]}` : LABELS[status];
}type OrderStatus = "draft" | "paid" | "shipped" | "cancelled";
const LABELS: Record<OrderStatus, string> = {
draft: "Draft",
paid: "Paid",
shipped: "On its way",
cancelled: "Cancelled",
};
const ICONS: Partial<Record<OrderStatus, string>> = { shipped: "truck", cancelled: "cross" };
export function badge(status: OrderStatus): string {
const icon = ICONS[status];
return icon ? `${icon} ${LABELS[status]}` : LABELS[status];
}jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} LABELS must have all four statuses; ICONS may have any of them. Reading LABELS[status] gives a string, and ICONS[status] gives string | undefined, which is why badge checks it.
Why it works
Record<K, V> is an object type with one property per member of K, each of type V. With a union of four strings for K, that is four required properties, and the object literal is checked against them like any other object type: nothing missing, nothing extra, every value a string. Partial makes each property optional, so its lookups include undefined.
The same thing in TypeScript
The TypeScript equivalent tab annotates const LABELS: Record<OrderStatus, string>. The type is identical; only the annotation moves from the JSDoc to the variable.
What goes wrong
Add "cancelled" to the union and forget the label, and the object fails where it is declared (TS2741). A key outside the union fails on its own line (TS2353):
/** @typedef {"draft" | "paid" | "shipped" | "cancelled"} OrderStatus */
/** @type {Record<OrderStatus, string>} */
const LABELS = {error TS2741: Property 'cancelled' is missing in type '{ draft: string; paid: string; shipped: string; }' but required in type 'Record<OrderStatus, string>'.
draft: "Draft",
paid: "Paid",
shipped: "On its way",
};
/** @type {Record<OrderStatus, string>} */
const COLORS = {
draft: "gray",
paid: "green",
shipped: "blue",
cancelled: "red",
refunded: "orange",error TS2353: Object literal may only specify known properties, and 'refunded' does not exist in type 'Record<OrderStatus, string>'.
};
export { LABELS, COLORS };jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} Gotchas
- The error points at the declaration. TS2741 is reported on the variable, not inside the object; the message names the missing key.
- Lookups with a plain
stringfail.LABELS[someString]is TS7053; the key must be anOrderStatus. See Object.keys for keys you get from the object itself. - Keep the union in one place. A const object and a
@typedefof its values give you both the runtime list of statuses and the union forRecord.
Questions
What is the difference between Record<OrderStatus, string> and Record<string, string>?
The first has exactly the keys in the union, all required. The second accepts any string key and requires none, so it cannot report a missing status.
Can I write the same type without Record?
Yes, as a mapped type: {{ [S in OrderStatus]: string }}. Record<K, V> is defined that way in TypeScript's library; Record is the shorter spelling.
Sources
- Utility Types (Record) · TypeScript handbook
- Mapped Types · TypeScript handbook
Last reviewed 21 October 2026.