Ivan Rubinson
Ivan Rubinson

Reputation: 3329

How do I declare object value type without declaring key type?

Problem statement

I'd like to declare mapper1 so that it's values can only be Type1, and mapper2 so that it's values can only be Type2. How do I do that without declaring the key type as well?

Background

In TypeScript, I have:

import Bar1 from './bar1'; // Type1
import Bar2 from './bar2'; // Type1
import Bar3 from './bar3'; // Type2
import Bar4 from './bar4'; // Type2

const mapper1 = {
  foo1: bar1,
  foo2: bar2,
} as const;
const mapper2 = {
  foo3: bar3,
  foo4: bar4,
} as const;
export type MapperKeys = keyof typeof mapper1 | keyof typeof mapper2;

bar1 and bar2 have the same type (Type1). bar3 and bar4 have the same type (Type2). Type1 is different from Type2.

MapperKeys is the union of the keys of mapper1 and mapper2 ('foo1' | 'foo2' | 'foo3' | 'foo4').

What I tried

Approach 1:

const mapper1: Record<string, Type1> = {
  foo1: bar1,
  foo2: bar2,
} as const;
const mapper2: Record<string, Type2> = {
  foo3: bar3,
  foo4: bar4,
} as const;

but now MapperKeys is 'string'. I want it to be the union of the keys of mapper1 and mapper2 ('foo1' | 'foo2' | 'foo3' | 'foo4')

Approach 2:

const mapper1: Record<'foo1' | 'foo2', Type1> = {
  foo1: bar1,
  foo2: bar2,
} as const;
const mapper2: Record<'foo3' | 'foo4', Type2> = {
  foo3: bar3,
  foo4: bar4,
} as const;

This works but isn't DRY.

Upvotes: 7

Views: 7689

Answers (2)

jcalz
jcalz

Reputation: 327624

If you use a type annotation on a variable like const x: T, or a type assertion on an expression like x as T, then you're telling the compiler to treat the variable or value as that type. This essentially throws away information about any more specific type that the compiler may have inferred*. The type of x will be widened to T:

const badMapper1: Record<string, Type1> = { foo1: bar1, foo2: bar2 };
const badMapper2 = { foo3: bar3, foo4: bar4 } as Record<string, Type2>;    

export type BadMapperKeys = keyof typeof badMapper1 | keyof typeof badMapper2;
// type BadMapperKeys = string

Instead, you're looking for something like the satisfies operator which was introduced in TypeScript 4.9. The idea is that an expression like x satisfies T verifies that x is assignable to type T without widening it to T. With that operator you could say

const mapper1 = { foo1: bar1, foo2: bar2 } satisfies { [key: string]: Type1 }
const mapper2 = { foo3: bar3, foo4: bar4 } satisfies { [key: string]: Type2 }

export type MapperKeys = keyof typeof mapper1 | keyof typeof mapper2;
// type MapperKeys = "foo1" | "foo2" | "foo3" | "foo4"

and be done.


For versions of TypeScript before 4.9 you can write helper functions to behave similarly. The general form is something like this:

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

And then instead of x satisfies T you write (the more cumbersome) satisfies<T>()(x). This works because satisfies<T>() produces an identity function of the form <U extends T>(u: U)=>u where the type of the input U is constrained to T, and the return type is the narrower type U and not the wider type T.

Let's try it:

const mapper1 = satisfies<Record<string, Type1>>()({ foo1: bar1, foo2: bar2 });
const mapper2 = satisfies<Record<string, Type2>>()({ foo3: bar3, foo4: bar4 });
export type MapperKeys = keyof typeof mapper1 | keyof typeof mapper2;
// type MapperKeys = "foo1" | "foo2" | "foo3" | "foo4"

Looks good!


In your case you specifically asked to specify the object value type but not the keys. If you want you can adapt the satisfies function so that you specify the property value type T and let the compiler infer just the keys. Something like this:

const satisfiesRecord = <T,>() => <K extends PropertyKey>(rec: Record<K, T>) => rec;

You can see that it behaves similarly:

const mapper1 = satisfiesRecord<Type1>()({ foo1: bar1, foo2: bar2, });
const mapper2 = satisfiesRecord<Type2>()({ foo3: bar3, foo4: bar4, });
export type MapperKeys = keyof typeof mapper1 | keyof typeof mapper2;
// type MapperKeys = "foo1" | "foo2" | "foo3" | "foo4"

Playground link to code


*This is not strictly true when you annotate a variable as a union type; in such cases the compiler will narrow the type of the variable upon assignment. But since Record<string, Type1> is not a union type, this is not applicable to the current situation.

Upvotes: 16

Using explicit type disables immutable assertion. Consider using either explicit type or as const assertion.

In order to achieve desired behavior you should use helper function and static validation:

type Type1 = { type: 1 }
type Type2 = { type: 2 }

const bar1: Type1 = { type: 1 }
const bar2: Type1 = { type: 1 }

const bar3: Type2 = { type: 2 }
const bar4: Type2 = { type: 2 }

// credits goes to https://stackoverflow.com/questions/50374908/transform-union-type-to-intersection-type/50375286#50375286
type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

// credits goes to https://stackoverflow.com/questions/53953814/typescript-check-if-a-type-is-a-union
type IsUnion<T> = [T] extends [UnionToIntersection<T>] ? false : true

type IsValueValid<Obj> = Obj extends Record<infer _, infer Value> ? IsUnion<Value> extends true ? never : Obj : never

const builder = <Key extends string, Value>(obj: IsValueValid<Record<Key, Value>>) => obj

const result1 = builder({
  foo1: bar1,
  foo2: bar2,
}) // ok, all values have same type

result1.foo1 // ok
result1.foo2 // ok

const result2 = builder({
  foo1: bar3,
  foo2: bar4,
}) // ok, all values have same type

const result3 = builder({
  foo1: bar1,
  foo2: bar4,
}) // expected error, values have different type

Playground

IsUnion - checks whether object values type is a union or not. If values have different type then we should consider this object as an invalid. This is exactly what we are doing in IsValueValid. This utility type return never if provided argument does not meet our requirements.

Upvotes: 0

Related Questions