Instead of using the enum feature in TypeScript (which might have unexpected behavior during iteration — see reverse mappings for numeric enums in the TS handbook) — you can use the equivalent readonly objects and types (use the same name for the value and the type to emulate enum behavior). Then wrapped iteration is straightforward and predictable:
TS Playground
type Values<T> = T[keyof T];
type AnyEnum<Values extends string | number = string | number> =
Readonly<Record<string, Values>>;
function getOffsetValue <Enum extends AnyEnum>(
e: Enum,
current: Values<Enum>,
distance: number,
): Values<Enum> {
const values = Object.values(e) as Values<Enum>[];
// You could just do this:
// const index = (values.indexOf(current) + distance) % values.length;
let index = values.indexOf(current);
// But it's safer to validate at runtime:
if (index === -1) throw new TypeError('Value not found');
index = (index + distance) % values.length;
return values[index < 0 ? values.length + index : index]!;
}
function getNextValue <Enum extends AnyEnum>(
e: Enum,
current: Values<Enum>,
): Values<Enum> {
return getOffsetValue(e, current, 1);
}
function getPreviousValue <Enum extends AnyEnum>(
e: Enum,
current: Values<Enum>,
): Values<Enum> {
return getOffsetValue(e, current, -1);
}
// Your enums as objects created using "const assertions" —
// the result is readonly properties and literal value types:
const Ed = {
up: 0,
down: 1,
left: 2,
right: 3,
} as const;
type Ed = Values<typeof Ed>;
//^? type Ed = 0 | 1 | 2 | 3
const Es = {
A: 'a',
B: 'b',
C: 'c',
} as const;
type Es = Values<typeof Es>;
//^? type Es = "a" | "b" | "c"
// Usage:
// Numeric enum:
const left = getOffsetValue(Ed, Ed.down, 5);
//^? 0 | 1 | 2 | 3
console.log('left === Ed.left', left === Ed.left); // true
const up = getNextValue(Ed, Ed.right);
console.log('up === Ed.up', up === Ed.up); // true
const right = getPreviousValue(Ed, Ed.up);
console.log('right === Ed.right', right === Ed.right); // true
// String enum:
const b = getOffsetValue(Es, Es.A, -2);
console.log('b === Es.B', b === Es.B); // true
const a = getNextValue(Es, Es.C);
//^? "a" | "b" | "c"
console.log('a === Es.A', a === Es.A); // true
const c = getPreviousValue(Es, Es.A);
console.log('c === Es.C', c === Es.C); // true
// Full iteration example from your dataset:
for (const [name, e] of [['Ed', Ed], ['Es', Es]] as [string, AnyEnum][]) {
console.log(`${name}:`);
for (const current of Object.values(e)) {
const previous = getPreviousValue(e, current);
const next = getNextValue(e, current);
console.log(`${JSON.stringify(previous)} <- ${JSON.stringify(current)} -> ${JSON.stringify(next)}`);
}
}
// Examples of compiler safety:
getNextValue(Ed, Es.A); /*
~~~~
Argument of type '"a"' is not assignable to parameter of type 'Ed'.(2345) */
getNextValue(Es, 'c'); // ok
getNextValue(Es, 'd'); /*
~~~
Argument of type '"d"' is not assignable to parameter of type 'Es'.(2345) */
// Where runtime validation matters:
/*
This object fits the constraint criteria, but wasn't created with literal
properties — it uses an index signature. A compiler error doesn't occur
because `5` is assignable to `number` (the types are ok), but the input data
is obviously invalid (it's out of range). If there were no runtime validation
to throw an exception on invalid inputs, the behavior would be unexpected:
*/
getNextValue({a: 1, b: 2} as Record<string, number>, 5);
Compiled JS from the playground:
"use strict";
function getOffsetValue(e, current, distance) {
const values = Object.values(e);
// You could just do this:
// const index = (values.indexOf(current) + distance) % values.length;
let index = values.indexOf(current);
// But it's safer to validate at runtime:
if (index === -1)
throw new TypeError('Value not found');
index = (index + distance) % values.length;
return values[index < 0 ? values.length + index : index];
}
function getNextValue(e, current) {
return getOffsetValue(e, current, 1);
}
function getPreviousValue(e, current) {
return getOffsetValue(e, current, -1);
}
// Your enums as objects created using "const assertions" —
// the result is readonly properties and literal value types:
const Ed = {
up: 0,
down: 1,
left: 2,
right: 3,
};
//^? type Ed = 0 | 1 | 2 | 3
const Es = {
A: 'a',
B: 'b',
C: 'c',
};
//^? type Es = "a" | "b" | "c"
// Usage:
// Numeric enum:
const left = getOffsetValue(Ed, Ed.down, 5);
//^? 0 | 1 | 2 | 3
console.log('left === Ed.left', left === Ed.left); // true
const up = getNextValue(Ed, Ed.right);
console.log('up === Ed.up', up === Ed.up); // true
const right = getPreviousValue(Ed, Ed.up);
console.log('right === Ed.right', right === Ed.right); // true
// String enum:
const b = getOffsetValue(Es, Es.A, -2);
console.log('b === Es.B', b === Es.B); // true
const a = getNextValue(Es, Es.C);
//^? "a" | "b" | "c"
console.log('a === Es.A', a === Es.A); // true
const c = getPreviousValue(Es, Es.A);
console.log('c === Es.C', c === Es.C); // true
// Full iteration example from your dataset:
for (const [name, e] of [['Ed', Ed], ['Es', Es]]) {
console.log(`${name}:`);
for (const current of Object.values(e)) {
const previous = getPreviousValue(e, current);
const next = getNextValue(e, current);
console.log(`${JSON.stringify(previous)} <- ${JSON.stringify(current)} -> ${JSON.stringify(next)}`);
}
}