Pierre Poupin
Pierre Poupin

Reputation: 113

Type-checking the keys of a const literal without losing the type

I'd like to have a constant that references keys of an interface. I want this constant to be type-checked to be sure that the keys are properly named, and I also want them to be typed as literals.

If I do the following:

interface MyInterface {
  keyA: string;
  keyB: string;
}

export const MY_KEYS: Record<string, keyof MyInterface> = {
  CONSTANT_KEY_A: 'keyA',
  CONSTANT_KEY_B: 'keyB',
} as const;

MY_KEYS.CONSTANT_KEY_A

then MY_KEYS.CONSTANT_KEY_A is of type 'keyA' | 'keyB'. But I want it to be 'keyA'!

If I remove Record<string, keyof MyInterface> then it will work, but then my keys are no longer type checked to be in the keys of MyInterface.

Any idea on how I can achieve that?

I could for example add a second dead variable that does the Record check and keep my first one only with as const and not casted as Record, but it's pretty verbose and not really clear.

Thank you!

Upvotes: 6

Views: 716

Answers (2)

Max Stevens
Max Stevens

Reputation: 166

For anyone on typescript 4.9.5 or higher, you can use satisfies (link):

export const MY_KEYS = {
  CONSTANT_KEY_A: 'keyA',
  CONSTANT_KEY_B: 'keyB',
} as const satisfies Record<string, keyof MyInterface>;

MY_KEYS.CONSTANT_KEY_A
// (property) CONSTANT_KEY_A: "keyA"

Upvotes: 1

jcalz
jcalz

Reputation: 329248

Often in cases like this I introduce a helper function which checks that a value is assignable to a type without widening the value to that type. The general version of the helper function looks like this:

const checkType = <T>() => <U extends T>(u: U) => u;

And then to have it check a particular type you call it and manually specify that type:

const checkKeys = checkType<Record<string, keyof MyInterface>>();

Now you have a function which will only accept values of the right type and will give you the expected error when you do something wrong:

export const MY_KEYS = checkKeys({
  CONSTANT_KEY_A: 'keyA',
  CONSTANT_KEY_B: 'keyB',
  // CONSTANT_KEY_C: 'keyC', // error! '"keyC"' is not assignable to  '"keyA" | "keyB"'.
})

but the output of the function is not widened, as desired:

MY_KEYS.CONSTANT_KEY_A // "keyA"

Note that you don't even need as const because the compiler sees that you want to interpret the object literal as assignable to Record<string, keyof MyInterface> and therefore does not widen the literal string values to string. You can still use as const if it helps you in other ways, though.


Conceptually this isn't really different from your "dead variable" idea, and certainly at runtime (() => u => u)()(someValue) is a weird way of writing someValue. But without a built-in "verify assignability without widening" operator in TypeScript (like maybe microsoft/TypeScript#30809?), this is the best way I know how to do it in general.

Okay, hope that helps; good luck!

Playground link to code

Upvotes: 6

Related Questions