How to force interface to "implement" keys of enum in typescript 3.0?
Asked Answered
R

7

5

Suppose I have some enum E { A = "a", B = "b"}. I would like to force some interfaces or types(for the sake of readability I'll mention only interfaces) to have all the keys of E. However, I want to specify the type of each field separately. Therefore, { [P in E]: any } or even { [P in E]: T } aren't proper solutions.

For instance, the code may contain two interfaces implementing E:

  • E { A = "a", B = "b"}
  • Interface ISomething { a: string, b: number}
  • Interface ISomethingElse { a: boolean, b: string}

As E extends during development it might become:

  • E { A = "a", B = "b", C="c"}
  • Interface ISomething { a: string, b: number, c: OtherType}
  • Interface ISomethingElse { a: boolean, b: string, c: DiffferntType}

And a few hours later:

  • E { A = "a", C="c", D="d"}
  • Interface ISomething { a: string, c: ChosenType, d: CarefullyChosenType}
  • Interface ISomethingElse { a: boolean, c: DiffferntType, d: VeryDifferentType}

And so on and so forth. Hence, from https://www.typescriptlang.org/docs/handbook/advanced-types.html it looks like it's not supported yet. Is there any typescript hack I am missing?

Russel answered 13/8, 2018 at 20:7 Comment(0)
T
14

I guess you're committed to writing out both the enum and the interface, and then hoping TypeScript will warn you the interface is missing keys from the enum (or maybe if it has extra keys)?

Let's say you have

enum E { A = "a", B = "b", C="c"};
interface ISomething { a: string, b: number, c: OtherType};

You can use conditional types to make TypeScript figure out if any constituents of E are missing from the keys of ISomething:

type KeysMissingFromISomething = Exclude<E, keyof ISomething>;

This type should be never if you don't have any keys missing from ISomething. Otherwise, it will be one of the values of E like E.C.

You can also make the compiler figure out if ISomething has any keys which are not constituents of E, also using conditional types... although this is more involved because you can't quite manipulate enums programmatically in expected ways. Here it is:

type ExtraKeysInISomething = { 
  [K in keyof ISomething]: Extract<E, K> extends never ? K : never 
}[keyof ISomething];

Again, this will be never if you don't have extra keys. Then, you can force a compile-time error if either one of these are not never, by using generic constraints along with default type parameters:

type VerifyISomething<
  Missing extends never = KeysMissingFromISomething, 
  Extra extends never = ExtraKeysInISomething
> = 0;

The type VerifyISomething itself is not interesting (it is always 0), but the generic parameters Missing and Extra will give you errors if their respective default values are not never.

Let's try it out:

enum E { A = "a", B = "b", C = "c" }
interface ISomething { a: string, b: number, c: OtherType }
type VerifyISomething<
  Missing extends never = KeysMissingFromISomething,
  Extra extends never = ExtraKeysInISomething
  > = 0; // no error

and

enum E { A = "a", B = "b", C = "c" }
interface ISomething { a: string, b: number } // oops, missing c
type VerifyISomething<
  Missing extends never = KeysMissingFromISomething, // error!
  Extra extends never = ExtraKeysInISomething
  > = 0; // E.C does not satisfy the constraint

and

enum E { A = "a", B = "b", C = "c" }
interface ISomething { a: string, b: number, c: OtherType, d: 1} // oops, extra d
type VerifyISomething<
  Missing extends never = KeysMissingFromISomething,
  Extra extends never = ExtraKeysInISomething // error!
  > = 0; // type 'd' does not satisfy the constraint

So all that works... but it's not pretty.


A different hacky way is to use a dummy class whose sole purpose is to scold you if you don't add the right properties:

enum E { A = "a", B = "b" , C = "c"};
class CSomething implements Record<E, unknown> {
  a!: string;
  b!: number;
  c!: boolean;
}
interface ISomething extends CSomething {}

If you leave out one of properties, you get an error:

class CSomething implements Record<E, unknown> { // error!
  a!: string;
  b!: number;
}
// Class 'CSomething' incorrectly implements interface 'Record<E, unknown>'.
// Property 'c' is missing in type 'CSomething'.

It doesn't warn you about extra properties, although maybe you don't care?


Anyway, hope one of those works for you. Good luck.

Tessatessellate answered 14/8, 2018 at 1:40 Comment(5)
The dummy class solution works great - thank you! Is there any good reason why ISomething cannot implement Record<E, unknown> directly? It seems like it could be checked simply during compilation time. – Russel
Only class definitions can use implements. So interface ISomething implements Record<E, unknonwn> { ... } is an error. You can use extends as in interface ISomething extends Record<E, unknown> {}, but that no longer warns you about missing properties... ISomething will already have properties with keys from E and with the type unknown. – Tessatessellate
Can this be turned into a utility type for better UX? i.e. export type MatchesKeysOfEnum<T, Enum> = ??? – Trevethick
πŸ€·β€β™‚οΈ Comments on 4-year old answers aren't great places to get help. If I get a chance to look into this I will, but I can't guarantee it. – Tessatessellate
For future readers, you will want to use the new satisfies operator: const myObject = {} satisfies Record<MyEnum, string> – Trevethick
H
4

You can just use a mapped type over the enum:

enum E { A = "a", B = "b"}

type AllE = { [P in E]: any }

let o: AllE = {
    [E.A]: 1,
    [E.B]: 2
};

let o2: AllE = {
    a: 1,
    b :2
}

Playground link

Edit

If you want the newly created object to maintain the original property types, you will to use a function. We need the function to help with the inference the actual type of the newly created object literal while still constraining it to have all keys of E

enum E { A = "a", B = "b" };


function createAllE<T extends Record<E, unknown>>(o: T) : T {
    return o
}

let o = createAllE({
    [E.A]: 1,
    [E.B]: 2
}); // o is  { [E.A]: number; [E.B]: number; }

let o2 = createAllE({
    a: 1,
    b: 2
}) // o2 is { a: number; b: number; }


let o3 = createAllE({
    a: 2
}); // error

Playground link

Horatius answered 13/8, 2018 at 20:18 Comment(2)
Not exactly, since I do want to specify the types of [E.A] and [E.B](and later on the type of [E.C] etc.). I'll edit the question to clarify it. – Russel
@Russel edited the question to preserve types. If you want a named interface that scolds you if you don't have all fields, jcalz answer deals with that very well. – Horatius
S
3

Use a Record. Just put the Enum type in the first position. Don't use keyof or anything else.

export enum MyEnum {
  FIRST = 'First',
  SECOND = 'Second',
}

export const DISPLAY_MAP: Record<MyEnum, string> = {
  [MyEnum.FIRST]: 'First Map',
  [MyEnum.SECOND]: 'Second Map',
}

If you are missing one of the properties TypeScript will yell at you.

Stamm answered 13/1, 2020 at 23:8 Comment(0)
B
2

If you don't want to use a string enum then you can do something like the following:

enum E {
  A,
  B,
  C,
}

type ISomething = Record<keyof typeof E, number>;

const x: ISomething = {
  A: 1,
  B: 2,
  C: 3,
};
Bolme answered 18/11, 2019 at 10:42 Comment(0)
J
1

If you're okay using a type (rather than an interface), there's actually a pretty trivial using the built-in Record type:

enum E { A = "a", B = "b", C="c"}
type ISomething = Record<E, { a: string, b: number, c: OtherType}>
type ISomethingElse = Record<E, { a: boolean, b: string, c: DifferentType}>

Doing it this way, Typescript will warn you if ISomething or ISomethingElse omit any of the keys in the enum.

Jackshaft answered 25/7, 2019 at 17:23 Comment(2)
I have tried this in the playground (version 3.9.2) and it doesn't seem to warn me if I leave off any values in the record definition. – Unanimity
That's weird man. It really does. For instance. the following code will not compile: enum E { A = "a", B = "b", C = "c" }; type ISomething = Record<E, { a: string; b: number }>; const foo: ISomething = { [E.A]: { a: "asdf", b: 123 } }; – Jackshaft
T
1

jcalz's excellent answer above (i.e. the highest-voted answer) does not leverage the satisfies operator that was released in TypeScript version 4.9, which is intended to address exactly this issue.

You use it like this:

enum MyEnum {
  Value1,
  Value2,
  Value3,
}

const myObject = {
  [MyEnum.Value1]: 123,
  [MyEnum.Value2]: 456,
  [MyEnum.Value3]: 789,
} satisfies Record<MyEnum, number>;

This obviates the need for all kinds of unnecessary boilerplate which we you will see in the other answers here.

However, note that you can't use the satisfies operator on an interface or type. So in this case, we can use a helper function like this:

enum MyEnum {
  Value1,
  Value2,
  Value3,
}

interface MyInterface {
  [MyEnum.Value1]: 123,
  [MyEnum.Value2]: 456,
  [MyEnum.Value3]: 789,
};

validateInterfaceMatchesEnum<MyInterface, MyEnum>();

And then you can put the helper function in your standard library, which looks like this:

/**
 * Helper function to validate that an interface contains all of the keys of an enum. You must
 * specify both generic parameters in order for this to work properly (i.e. the interface and then
 * the enum).
 *
 * For example:
 *
 * ```ts
 * enum MyEnum {
 *   Value1,
 *   Value2,
 *   Value3,
 * }
 *
 * interface MyEnumToType {
 *   [MyEnum.Value1]: boolean;
 *   [MyEnum.Value2]: number;
 *   [MyEnum.Value3]: string;
 * }
 *
 * validateInterfaceMatchesEnum<MyEnumToType, MyEnum>();
 * ```
 *
 * This function is only meant to be used with interfaces (i.e. types that will not exist at
 * run-time). If you are generating an object that will contain all of the keys of an enum, use the
 * `satisfies` operator with the `Record` type instead.
 */
export function validateInterfaceMatchesEnum<
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  T extends Record<Enum, unknown>,
  Enum extends string | number,
>(): void {}

(Or you can add an npm dep of isaacscript-common-ts, which is a library that provides some helper functions like this.)


OLD OUTDATED ANSWER:

jcalz's excellent answer above (i.e. the highest-voted answer) does not work in the latest versions of TypeScript, throwing the following error:

All type parameters are unused. ts(62305)

This is because the two generic parameters are unused. We can fix this by simply using an underscore as the first character of the variable name, like this:

// Make copies of the objects that we need to verify so that we can easily
// re-use the code block below
type EnumToCheck = MyEnum;
type InterfaceToCheck = MyInterface;

// Throw a compiler error if InterfaceToCheck does not match the values of EnumToCheck
// From: https://stackoverflow.com/questions/51829842
type KeysMissing = Exclude<EnumToCheck, keyof InterfaceToCheck>;
type ExtraKeys = {
  [K in keyof InterfaceToCheck]: Extract<EnumToCheck, K> extends never
    ? K
    : never;
}[keyof InterfaceToCheck];
type Verify<
  _Missing extends never = KeysMissing,
  _Extra extends never = ExtraKeys,
> = 0;

Note that if you use ESLint, you might have to slap a eslint-disable-line comment on some of the lines, since:

  1. the "Verify" type is unused
  2. the two generic parameters are unused
Trevethick answered 6/8, 2021 at 20:2 Comment(2)
Can you give an example how to implement that? – Mona
I updated my answer just now, you can read it and upvote if useful. – Trevethick
E
0

If it's enough for you that typescript compiler will throw error in case not all enum values are used as interface keys this could be a concise way to achieve this:

declare const checkIfAllKeysUsed = SomeInterface[SomeInterfaceKeysEnum];
Ellery answered 27/7, 2023 at 19:23 Comment(0)

© 2022 - 2024 β€” McMap. All rights reserved.