Home > Net >  Is there a way to assure that every value from a type exists in an enum?
Is there a way to assure that every value from a type exists in an enum?

Time:07-30

I have defined an enum in Prisma (so not a TypeScript enum), and I'm wondering if I can sync a TypeScript String enum to the generate Type created by the Prisma Client. Here are the relevant details.

export const GroupInvitationStatus: {
  Pending: 'Pending',
  Accepted: 'Accepted',
  Declined: 'Declined'
};

export type GroupInvitationStatus = (typeof GroupInvitationStatus)[keyof typeof GroupInvitationStatus]

When I Import GroupInvitationStatus from the Prisma Client, I see that it is:

(alias) type GroupInvitationStatus = "Pending" | "Accepted" | "Declined"

My goal is to make sure that in a defined TypeScript string enum that I create, that every possible value from the Prisma enum (as referenced through the imported type above) is specified in the TypeScript enum.

Is this possible - I've read the TypeScript documentation on enums and have searched for a solution, but so far haven't found one.

Is it overkill? Should I just consider using the type directly and skipping the enum?

Edit 1 to add: I found this answer, which seems more or less like it does what I need it to.

Is there a way to dynamically generate enums on TypeScript based on Object Keys?

However, I can't seem to get TypeScript to "know" when changes are made to the Prisma Client, which exists in the node modules.

So this solution, while I think it's better, doesn't help my case any. I'm beginning to think I can just use the generated const instead of the type as an enum - it seems functionally identical.

New edit: Here is a relevant Code Sandbox, where I define a "prisma-client.ts" which is a spoofed representation of exports from a node module in my project, and "target-file.enum.ts" where I use the exports from the Prisma Client.

https://codesandbox.io/s/sweet-curran-n7ruyt?file=/src/target-file.enum.ts

CodePudding user response:

Reproduced in Typescript playground

It should work as described here.

which is similar like putting this in a .d.ts file:

declare const GroupInvitationStatus: {
  Pending: 'Pending',
  Accepted: 'Accepted',
  Declined: 'Declined'
};

this is working like a enum. I would not recommend to write a duplicate Enum for your own.

CodePudding user response:

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"
}

  • Related