This typescript type definition function (provided by @jcalz) enables strict excess property checking in assignment of a literal object initializer to a union of types, in some cases.
Note: The definition of strict excess property checking in assignment of a literal object initializer to a union in this question is described in detail below
type AllKeys<T> = T extends unknown ? keyof T : never;
type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;
type _ExclusifyUnion<T, K extends PropertyKey> =
T extends unknown ? Id<T & Partial<Record<Exclude<K, keyof T>, never>>> : never;
type ExclusifyUnion<T> = _ExclusifyUnion<T, AllKeys<T>>;
It works in correctly in 1-level objects without index signatures, for example
type sth = { value: number, data: string } | { value: number, note: string };
const a: sth = { value: 7, data: 'test' };
const b: sth = { value: 7, note: 'hello' };
const c: sth = { value: 7, data: 'test', note: 'hello' };
type xsth = ExclusifyUnion<sth>;
/* type xsth = {
value: number;
data: string;
note?: undefined;
} | {
value: number;
note: string;
data?: undefined;
} */
const z: xsth = { value: 7, data: 'test', note: 'hello' }; // error!
/* Type '{ value: number; data: string; note: string; }' is not assignable to
type '{ value: number; data: string; note?: undefined; } |
{ value: number; note: string; data?: undefined; }' */
However it fails when a type with an index signature key is included.
type sthA = { value: string, data: string };
type sthB = { value: number, [key:string]:number };
type sth = sthA | sthB;
type EU = ExclusifyUnion<sth>
const t1:EU = {value:'a', data:'b'} // error
/*
const t1: {
[x: string]: undefined;
[x: number]: undefined;
value: string;
data: string;
} | {
[x: string]: number;
value: number;
}
Type '{ value: string; data: string; }' is not assignable to type
'{ [x: string]: undefined; [x: number]: undefined; value: string; data: string; }
| { [x: string]: number; value: number; }'.
Type '{ value: string; data: string; }' is not assignable to type
'{ [x: string]: number; value: number; }'.
Types of property 'value' are incompatible.
Type 'string' is not assignable to type 'number'.(2322)
*/
type AK = AllKeys<sth> /* string | number */
Can you amend it to handle types of objects with a signature index?
This questions definition of strict excess property checking in assignment of a literal object initializer to a union
"(ordinary) assigment" vs "strict assignment"
Typescript has two kinds of structural assignability, i.e., rules for valid assignments to types of objects with properties.
The first kind, which is the general rule, allows excess properties to be assigned, even though they are not defined in the type:
type T = { a:number, b:number };
let source= { a:1, b:2, c:3 };
let target:T= source // no error
The second kind is an exception to the general rule in the case of literal object declarations to a constant, i.e., a const
declaration. In this case excess properties are not allowed. In the scope of this discussion we will call this strict-assignment:
type T = { a:number, b:number };
const target:T= { a:1, b:2, c:3 }; // error:
/* Type '{ a: number; b: number; c: number; }' is not assignable to type 'T'.
Object literal may only specify known properties,
and 'c' does not exist in type 'T'.(2322)*/
Assignment of literal object initializers to a union of types
In Typescript a new type may be composed as the "union of types", and the notation is
type TU= T1|T2|T3
The formal Typescript definition of the union type() is described in Typescript Release notes 3.5:
In TypeScript 3.5, the type-checker at least verifies that all the provided properties belong to some union member and have the appropriate type, meaning that the sample above correctly issues an error. Note that partial overlap is still permitted as long as the property types are valid.
That is, by definition, the correct definition for Union in Typescript, and saying otherwise would be incorrect.
A more constrained use case - strict union
Suppose a programmer has a use case which requires defining a Typescript-union-similar-but-not-same entity with the following psuedo-code definition:
If
. type TU = StrictUnion(T1,...,Ti,...,TN)
then
. const t:TU = x
is a valid assignment if and only if
. const t:Ti = x
is a valid strict-assignment for at least one StrictUnion argument Ti
An obvious use-case for a strict-union is to disallow accidentally mixing properties from different object types. For this reason, within the scope of this SE question
. strict excess property checking in assignment of a literal object initializer to a union
is defined as being synonymous with
. strict-union
the latter being much shorter.
(Note: another use-case for the same strict-union is discussed in the appendix below)
In addition, it is preferred, but not required that StrictUnion(T1,..,TN)
can be written as a function of the Typescript union, i.e. StrictUnion(T1|..|TN)
, (which ExclusifyUnion(..)
does).
Back to the original question.
The question asked in this post is how to amend ExclusifyUnion
so that it handles the case of a type with an index signature property to give a result that is compatible with the above definition of strict-union.
The question also artificially restricts the domain of types and object initializers under consideration to be only 1-level deep, and not to be an edge case such a function with properties. However it must include the case of objects with an index signature.
It should be noted that Typescript's Union does extend to deeper objects and arrays - for example
type T1 = {a:number,b:number}
type T2 = {a:number, c: (T1|T2)[]}
const t:T1|T2 = {a:1,c:[{a:1,b:2,c:[{a:1,b:2}]}]} // legal Typescript
is allowed. Correspondingly strict-union must extend to cover the same domain, and would reject that assignment because t.c
is strict-assignable to neither T1
nor T2
. That case would be a separate question, unless the answer to this question happens to extend to include it.
Appendix:
Another use case for a strict-union - JSONSchema compatibility
Another use case for a strict-union is to define a type that can be implemented in JSONSchema without defining any custom keywords. The downside of defining custom keywords is discussed in the AJV manual:
The concerns you have to be aware of when extending JSON Schema standard with additional keywords are the portability and understanding of your schemas. You will have to support these keywords on other platforms and to properly document them so that everybody can understand and use your schemas.