Reputation: 3329
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?
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'
).
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'
)
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
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"
*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
Reputation: 33041
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
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