simPod
simPod

Reputation: 13456

Constrain object keys to enum's values while not requiring all keys to be present and object values are not considered undefined

There are a lot of similar issues here but they don't cover the checklist at the end of the post (especially the "value being undefined" part).

I have an enum

enum E {
  A = 'A',
  B = 'B'
}

Then I wanted to constrain my object value in state to { [key in E]: string } but that requires me to instantiate the object already will all enum's keys. This is not allowed:

const state: { [key in E]: string } = {};

Type '{}' is missing the following properties from type '{ A: string; B: string; }': A, B

So I tried to constrain it like this { [key in E]?: string }. That allows me to omit enum keys, thus allows to instantiate empty object {} and also checks that key values are in enum's range:

state.A = 'x'; // ok
state.C = 'y'; // gives error which is nice

But then I encountered issue when forEaching Object entries

Object.entries(state).forEach(([key, value]) => console.log(value === undefined));

Typescript thinks that value can be of type string|undefined but that is never true.

When using only string as a key, the value is not considered undefined

const state: { [key: string]: string } = {};

Playground example


How can I constrain object keys to enum values while their presence is not required and value is not undefined?

Checklist:

state.A = 'x' // ok
state.C = 'x' // error
Object.entries(state).forEach(([key, value])=>console.log(value.charAt(0))); // ok, no Object is possibly 'undefined'.(2532) error

TLDR: I want to get rid of the assert in the Example here

Upvotes: 5

Views: 6334

Answers (2)

Linda Paiste
Linda Paiste

Reputation: 42188

As far as typescript is concerned, having A omitted from the object is the same as having A explicitly set to undefined. It's fine to do this:

const state: { [key in E]?: string } = {};
state.A = undefined;

@captain-yossarian has given you a good but very complicated way to prevent this behavior. The easier thing to do is to simply filter out any undefined values.

This callback is a type guard which makes sure that the value in a [key, value] tuple is defined.

const isDefinedValue = <T,>(tuple: [string, T | undefined]): tuple is [string, Exclude<T, undefined>] => {
  return tuple[1] !== undefined;
}

We can then filter the Object.entries before using it.

Object.entries(state)
  .filter(isDefinedValue)
  .forEach(([key, value])=>console.log(value.charAt(0)));  // value is `string`

Upvotes: 6

enum E {
  A = 'A',
  B = 'B'
}

type O = keyof typeof E
const state: { [key in keyof typeof E]?: string } = {};

If you want to make state mutable, just use -readonly:

const state: { -readonly [key in keyof typeof E]?: string } = {};

How can I constrain object keys to enum values while their presence is not required and value is not undefined?

Could you please provide some pseudo code?

Because, if key is not required, and you will try to get the value of non existence key, according to JS standard you should receive undefined

UPDATE

enum E {
  A = 'A',
  B = 'B',
  C = 'C',
  D = 'D'
}

// credits goes to https://stackoverflow.com/a/50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;

// //https://github.com/microsoft/TypeScript/issues/13298#issuecomment-468114901
type UnionToOvlds<U> = UnionToIntersection<
  U extends any ? (f: U) => void : never
>;

type PopUnion<U> = UnionToOvlds<U> extends (a: infer A) => void ? A : never;

type IsUnion<T> = [T] extends [UnionToIntersection<T>] ? false : true;

type UnionToArray<T, A extends unknown[] = []> = IsUnion<T> extends true
  ? UnionToArray<Exclude<T, PopUnion<T>>, [PopUnion<T>, ...A]>
  : [T, ...A];

type State<T extends string, R extends string[] = []> = {
  [P in T]: IsUnion<T> extends true ? State<Exclude<T, P>, [...R, Exclude<T, P>]> : R
}[T]

// Array of all possible keys
type Result = UnionToArray<State<keyof typeof E>>

// convert union to object with appropriate keys
type MapPredicate<T> = UnionToIntersection<T extends string ? {
  [P in T]: string
} : never>

// don't allow empty object because value can't be undefined
type Empty = { __tag: 'empty' }

// iterate through array of strings
type MappedString<
  Arr,
  Result = Empty
  > = Arr extends []
  ? []
  : Arr extends [infer H]
  ? Result | MapPredicate<H>
  : Arr extends [infer Head, ...infer Tail]
  ? MappedString<[...Tail], Result | MapPredicate<Head>>
  : Readonly<Result>;


// iterate through array of array of string
type MappedArray<
  Arr extends Array<unknown>,
  Result extends Array<unknown> = []
  > = Arr extends []
  ? []
  : Arr extends [infer H]
  ? [...Result, MappedString<H>]
  : Arr extends [infer Head, ...infer Tail]
  ? MappedArray<[...Tail], [...Result, MappedString<Head>]>
  : Readonly<Result>;

type AllPossibleValues = MappedArray<Result>[number];

const A: AllPossibleValues = { A: 'A' }
const AB: AllPossibleValues = { A: 'A', B: 'B' }
const ABC: AllPossibleValues = { A: 'A', B: 'B', C: 'C' }
const ABCD: AllPossibleValues = { A: 'A', B: 'B', C: 'C', D: 'D' }
const CD: AllPossibleValues = { C: 'C', D: 'D' }
const AD: AllPossibleValues = { A: 'A', D: 'D' }
const BD: AllPossibleValues = { B: 'B', D: 'D' }

const BE: AllPossibleValues = {} // expected error
const QA: AllPossibleValues = {A:'A', Q:'Q'} // expected error

const state:AllPossibleValues={A:'A'}


const x = Object.entries(state).forEach(([key, value]) => { /* [key: string, value: string] */

})

Pros: No assertions, no type castings

Cons: I have to similar Mapped utils which I don't know how to refactor. But it doe not affect your compiled code anyway. Also, if you add 5th property to enum, above code will not compile, because of recursion restrictions :)

So, if your object has less then 5 props, you are good to go.

TypeScript allows you roughly ~50 recursion calls.

If you have obj with 5 props, you should create union with parseInt('11111',2) 31 items. I think because my rec MappedArray calls rec MappedString I reached this limit faster.

Playground link

Upvotes: 2

Related Questions