Below I'll detail a technique for creating what I call "synthetic string enums": runtime objects which have readonly keys and values equal to each other, derived from a tuple of string literals. The types involved in the creation steps of each of these can be constrained during the process, informed by an existing string union (your import). It's definitely verbose (but that's just the nature of TypeScript), but allows for flexibility and the type-correctness assurance that you're after.
What makes this whole thing possible is a higher-order function which uses a generic type constraint to produce an identity function whose input parameter is constrained by the original type.
I've commented the code heavily, so that you can have documentation in your codebase if you decide to use it. If anything is unclear (or perhaps I made a typo, etc.) feel free to ask for clarification in a comment.
I encourage you to view the code in the TypeScript Playground because of its IntelliSense and editing features.
// import type {GroupInvitationStatus} from 'some_module_specifier';
// Example of what's being imported in the commented statement above:
type GroupInvitationStatus = 'Accepted' | 'Declined' | 'Pending';
/**
* Returns a new identity function which accepts and returns a value,
* but will create a compiler error if the value does not extend
* the constraining type provided to this function
*/
function createConstrainedIdentityFn <Constraint = never>(): <T extends Constraint>(value: T) => T {
return value => value;
}
/** A very close approximation of the type of runtime object that is TS's string enum */
type StringEnumObj<StrUnion extends string> = { readonly [S in StrUnion]: S };
/** Takes a tuple of strings and creates an object with a key-value pair for each string (that's equal to it) */
function objectFromTuple <T extends readonly string[]>(
strings: T,
): StringEnumObj<T[number]> {
return Object.fromEntries(strings.map(s => [s, s])) as StringEnumObj<T[number]>; // Has to be asserted
}
// This is the only part that you'll need to "manually" update. It's also valuable
// to define it separately so that you can use the string literals to iterate keys
// in a type-safe way in other parts of your code
const groupInvitationStatusTuple = createConstrainedIdentityFn<readonly GroupInvitationStatus[]>()(
// The first invocation returns the identity function ^ ^
// The second invocation is where you provide the input argument >>^
['Accepted', 'Declined', 'Pending'] as const,
// ^^^^^^^^
// Be sure to use "as const" (a const assertion) so that this is inferred as
// a tuple of string literals instead of just an array of strings
);
// If you try to use it with a value that's not in the imported union, you'll get a compiler error.
// This prevents excess member types:
createConstrainedIdentityFn<readonly GroupInvitationStatus[]>()(['Accepted', 'Declined', 'Pending', 'nope'] as const); /*
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Argument of type 'readonly ["Accepted", "Declined", "Pending", "nope"]' is not assignable to parameter of type 'readonly GroupInvitationStatus[]'.
Type '"Accepted" | "Declined" | "Pending" | "nope"' is not assignable to type 'GroupInvitationStatus'.
Type '"nope"' is not assignable to type 'GroupInvitationStatus'.(2345) */
// Note that this doesn't guard against missing union constituents,
// but that'll be addressed in the next part, so don't worry.
createConstrainedIdentityFn<readonly GroupInvitationStatus[]>()(['Accepted', 'Declined'] as const); // Ok, but it'd be nice if this were an error
// Create the synthetic string enum runtime object. Shadowing the string union import name is absolutely fine
// because type names and value names can co-exist in TypeScript (since all types are erased at runtime).
// This is ideal in your case because it actually helps reproduce additional string enum-like behavior
// that I'll cover at the end.
const GroupInvitationStatus = createConstrainedIdentityFn<StringEnumObj<GroupInvitationStatus>>()(
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// This is the type that you ultimately want, and it's used to constrain what can go into the identity function
objectFromTuple(groupInvitationStatusTuple),
);
// If you try to create it with a tuple that has missing union constituents, you'll get a compiler error:
createConstrainedIdentityFn<StringEnumObj<GroupInvitationStatus>>()(
objectFromTuple(['Accepted', 'Declined'] as const), /*
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Argument of type 'StringEnumObj<"Accepted" | "Declined">' is not assignable to parameter of type 'StringEnumObj<GroupInvitationStatus>'.
Property 'Pending' is missing in type 'StringEnumObj<"Accepted" | "Declined">' but required in type 'StringEnumObj<GroupInvitationStatus>'.(2345) */
);
// Note that it doesn't guard against excess values,
// but that was already covered during the tuple creation step above.
createConstrainedIdentityFn<StringEnumObj<GroupInvitationStatus>>()(
objectFromTuple(['Accepted', 'Declined', 'Pending', 'nope'] as const), // Ok, but it'd be nice if this were an error
);
// Let's examine the shape of the created synthetic string enum runtime object:
GroupInvitationStatus.Accepted; // "Accepted"
GroupInvitationStatus.Declined; // "Declined"
GroupInvitationStatus.Pending; // "Pending"
// And in the console:
console.log(GroupInvitationStatus); // { Accepted: "Accepted", Declined: "Declined", Pending: "Pending" }
// Looks good!
// Lastly, some usage examples (data and types, just like a string enum from TS):
function printStatus (status: GroupInvitationStatus): void {
console.log(status);
}
printStatus(GroupInvitationStatus.Accepted); // OK, prints "Accepted"
printStatus(GroupInvitationStatus.Pending); // OK, prints "Pending"
// Using valid string literals is also ok with this technique:
printStatus('Declined'); // OK, prints "Declined"
// Using an invalid type produces an expected and welcome error:
printStatus('Debating'); /*
~~~~~~~~~~
Argument of type '"Debating"' is not assignable to parameter of type 'GroupInvitationStatus'.(2345) */
// Iterating keys:
// You can't use the "keys"/"entries" etc. methods
// on Object to iterate keys in a type-safe way:
for (const key of Object.keys(GroupInvitationStatus)) {
key; // string
}
// But you CAN use the tuple above:
for (const key of groupInvitationStatusTuple) {
key; // "Accepted" | "Declined" | "Pending"
}