4

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

simPod
  • 11,498
  • 17
  • 86
  • 139

2 Answers2

6

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`
Linda Paiste
  • 38,446
  • 6
  • 64
  • 102
2
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

  • Maybe it's not implemented in typescript yet? But how can I get a value for non-existing key if I'm looping over entries (key:value pairs) `Object.entries(state)`? – simPod Feb 18 '21 at 12:41
  • https://codesandbox.io/s/empty-glitter-ps18k?file=/src/index.ts There I have to use assert even though using `Object.entries()` should IMO rule out all `undefined` values. – simPod Feb 18 '21 at 13:18
  • @simPod please take a look, I made an update – captain-yossarian from Ukraine Feb 18 '21 at 17:16
  • Wow, exhaustive! Thank you and vote up! Though I'm a bit disappointed as I thought I'm only missing something. Forgotten `?` or `!` somewhere While it looks like it's not trivial. I'll keep acceptance candy in my pocket for a little longer if you don't mind. – simPod Feb 18 '21 at 19:39
  • Sure, I will happy if somebody will find some better way to handle it. – captain-yossarian from Ukraine Feb 18 '21 at 19:44