21

I would like to declare a type-enforced array of items and be able to derive a union type from it. This pattern works if you do not explicitly give a type to the items in the array. I am not sure how to best explain it so here is an example:

EXAMPLE 1

type Pair = {
  key: string;
  value: number;
};

const pairs: ReadonlyArray<Pair> = [
  { key: 'foo', value: 1 },
  { key: 'bar', value: 2 },
] as const;

type Keys = typeof pairs[number]['key']

EXAMPLE 2

type Data = {
  name: string;
  age: number;
};

const DataRecord: Record<string, Data> = {
  foo: { name: 'Mark', age: 35 },
  bar: { name: 'Jeff', age: 56 },
} as const;

type Keys = keyof typeof DataRecord;

Here is an example of deriving the keys when using as const. I want this same behavior but with the array being explicitly typed.

const pairs = [
  { key: 'foo', value: 1 },
  { key: 'bar', value: 2 },
] as const;

type Keys = typeof pairs[number]['key']; // "foo" | "bar"

desired value of keys: "foo"|"bar"

actual value of keys: string

Ben
  • 263
  • 1
  • 2
  • 6
  • 4
    I don't think you can do this dynamically the way you're trying to, you're conflating runtime values with compile-time types. You will have to give the `key` attribute of the `Pair` type the type you want, then it should work as you've written it. – Jared Smith Mar 02 '20 at 20:42
  • 4
    Does this answer your question? [Typescript derive union type from tuple/array values](https://stackoverflow.com/questions/45251664/typescript-derive-union-type-from-tuple-array-values) – vicpermir Mar 02 '20 at 20:42
  • @JaredSmith this should not be an issue at runtime. I'm using this to declare an arbitrary number of values that do not change during execution. This would be equivalent to setting key: "foo"|"bar" in the type declaration. – Ben Mar 02 '20 at 20:50
  • "this should not be an issue at runtime" --- typescript does not have runtime, so it **is** an issue to do it in runtime. – zerkms Mar 02 '20 at 21:02
  • @zerkms I am aware that typescript does not exist at runtime. The purpose of this is just to get all the possible values of keys so they can be used elsewhere. I am trying to override the string declaration from the as const declaration – Ben Mar 02 '20 at 21:06
  • `const DataRecord: Record` --- the type of `DataRecord` is a record with keys of `string` type, hence `keyof typeof DataRecord` is a string. It does not matter what value you assign to it given you declared the `DataRecord` type explicitly. – zerkms Mar 02 '20 at 21:09
  • @zerkms Thank you for taking the time to emphasize the issue at hand. I am asking if there is any way around this? Is there a way to have every entry in the Object/ Array be of a consistent type while having the keys exposed as a union type for use elsewhere in the codebase – Ben Mar 02 '20 at 21:14
  • 1
    @Ben allow me to be more specific: I don't think you can do this with a tuple of properties pulled out of mutable reference types the way you can with a tuple of immutable primitives. You can say `possibleKeys = ['foo', 'bar'] as const; type Keys = typeof possibleKeys[number]; type Pair = { key: Keys, value: number };` but you still need to explicitly enumerate the possible keys. – Jared Smith Mar 02 '20 at 21:15
  • [Playground POC](https://www.typescriptlang.org/play/#code/MYewdgzgLgBADiCECWAjANgUwNKYJ4QwC8MA2gOQBmII5ANDOagIYBO5AujM4aJFAG4AUFDxxMMAAqIIuAsRijxISvBlosciKTABXALapMrDsKUTJzZKwUBvGAGt8ALikytDAG7N0uzK71DYxgAX2EhPmh4K1YIVwAlTGYAE3B0PABBVlZmPAAeS2sAPgVSIRgYeyc8VyoaehhvX38YAEZQunLKxxdGFnYvHz9XACYOoS4eGEjBIRExCS0FcxVo621AoxMKas4gA) – Jared Smith Mar 02 '20 at 21:18
  • @Ben I don't think you can trick a compiler given you _explicitly_ specify the type you don't really want. – zerkms Mar 02 '20 at 21:18
  • @JaredSmith thank you for responding thoroughly. That is what I am currently doing but it is getting more tedious as the number of keys gets into the triple digits. I was hoping to find a way to skip that extra step and only declare my keys once – Ben Mar 02 '20 at 21:18

3 Answers3

15

The usual approaches are:

  • let TS infer the type of pairs by omitting the explicit type ReadonlyArray<Pair> (see answer)
  • give key in Pair the type "foo"|"bar"

If you don't want to do this, then the only way to infer your keys and restrict the type of pairs is to use a helper function. The Pair type will also be made generic to save the given key string literal types. You can use an IIFE to make the assignment compact:

type Pair<K = string> = {
    key: K;
    value: number;
};

const pairs = (<T>(p: readonly Pair<T>[]) => p)([
    { key: 'foo', value: 1 },
    { key: 'bar', value: 2 },
] as const) // readonly Pair<"foo" | "bar">[]

type Keys = typeof pairs[number]['key'] // "foo" | "bar"

Playground

bela53
  • 3,040
  • 12
  • 27
13

For a variable you can either let the compiler infer the type from initialization, or write it out explicitly. If you write it explicitly, as you have, then the initialization value is checked against the annotation, but the actual type of the initializer does not affect the type of the variable (so you lose the type information you want). If you let the compiler infer it, it is no longer possible to constrain the type to conform to a specific interface (as you seem to want)

The solution for this is to use a generic function to both constrain the value and infer it's actual type:

type Pair = {
  key: string;
  value: number;
};
function createPairsArray<T extends readonly Pair[] & Array<{key: V}>, V extends string>(...args: T) {
    return args
}

const pairs = createPairsArray(
  { key: 'foo', value: 1 },
  { key: 'bar', value: 2 },
)

type Keys1 = typeof pairs[number]['key']

type Data = {
  name: string;
  age: number;
};

function createDataObject<T extends Record<string, Data>>(arg: T) {
    return arg;
}
const DataRecord = createDataObject({
  foo: { name: 'Mark', age: 35 },
  bar: { name: 'Jeff', age: 56 },
})

type Keys2 = keyof typeof DataRecord;

Playground Link

Note: For the array case we need to strong arm the compiler a bit into inferring string literal types for key, hence the whole & Array<{key: V}>, where V is a type parameter extending string

Rich
  • 5,603
  • 9
  • 39
  • 61
Titian Cernicova-Dragomir
  • 230,986
  • 31
  • 415
  • 357
1

@bela53 answer is really good, until it didn't work for me.

The = string part in Pair generic declaration made the compiler infer the type of key as string, which defeated the whole purpose. So here's what fixed it for me:

type ProtoPair<K> = {
    key: K;
    value: number;
}; // no default for type argument - need to provide it explicitly

const pairs = (<T>(p: readonly Pair<T>[]) => p)([
    { key: 'foo', value: 1 },
    { key: 'bar', value: 2 },
] as const)

type Keys = typeof pairs[number]['key'] // "foo" | "bar";
type Pair = ProtoPair<Keys> // Pair with key: "foo" | "bar"

Hope that helps.

grreeenn
  • 2,287
  • 1
  • 20
  • 27