简体   繁体   中英

typescript type function - Can you amend this function for strict excess property checking so that it will handle a signature index?

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 */

typescript playground

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, ie, 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, ie, 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, ie 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.

TL;DR This is impossible.


We want to come up with a version of ExclusifyUnion that will transform a regular union A | B A | B into a different type that behaves like a "strict" version of A | B A | B , even in cases where either A or B has a string index signature .

By "strict" I mean that if an object literal {...} cannot be assigned directly to a variable of type A (due to excess property checking ) and if it cannot be assigned directly to a variable of type B , then it also cannot be assigned directly to a variable of type ExclusifyUnion<A | B> ExclusifyUnion<A | B> . And conversely, if an object literal {...} can be assigned to a variable of type ExclusifyUnion<A | B> ExclusifyUnion<A | B> , then it must be assignable to either a variable of type A or to a variable of type B .

This is not currently possible in TypeScript.

Without direct support for exact types as requested in microsoft/TypeScript#12936 , there's just no way to express ExclusifyUnion<A | B> ExclusifyUnion<A | B> as a specific type in general.


To demonstrate that this is not possible in general, let's look at a particular case:

type A = { a: 0 };
type B = { [k: string]: 1; }

Here, A has a known key a of type 0 and that's it. An "exclusive"/"exact"/"strict" version of A would only allow a value like {a: 0} to be assigned. Meanwhile, B is allowed to have any keys whatsoever, as long as every property value is of type 1 .

Let's make sure that the simple union A | B A | B does not behave strictly first:

type U = A | B;

const goodA: A = { a: 0 }; // ✔
const goodUnionA: U = { a: 0 }; // ✔

const goodB: B = { a: 1, c: 1 }; // ✔
const goodUnionB: U = { a: 1, c: 1 }; // ✔

const badA: A = { a: 0, c: 1 } // c is excess ✔
const badB: B = { a: 0, c: 1 } // a is wrong type ✔
const badUnionAB: U = { a: 0, c: 1 }; // ❌

const badA1: A = { a: 0, c: 0 }; // c is excess ✔
const badB1: B = { a: 0, c: 0 }; // a and c are wrong type ✔
const badUnionAB1: U = { a: 0, c: 0 }; // c is wrong type  ✔

In these examples, badUnionAB is a problem. The object literal {a: 0, c: 1} is not "strictly" assignable to A because c is an extra property. It is not assignable to B because a is the wrong type. But it is assignable to A | B A | B .

So A | B A | B is not sufficient, and we must try to change it into some stricter union of the form StrictA | StrictB StrictA | StrictB where StrictA and StrictB allow exactly the same object literals as A and B respectively.


We cannot touch B at all. We do not want to, for example, make StrictB prohibit the a key, as this does not do what we want. We want { a: 1 } to be assignable to StrictB . Once a type has an index signature, the "strict" version of it cannot be modified so as to prohibit anything further. So we are stuck with

type StrictB = B;

Let's try to make StrictA . With no support for exact types, one cannot say " StrictA only has an a property and no others". And it is fruitless to attempt to list out all possible excess keys of A . It would look something like

type StrictA = { a: 0, b?: never, c?: never, d?: never, e?: never, /*...∞*/ };

but of course there is no way to enumerate all possible keys. Since a valid value of type B might have any key at all (eg, "kdfsf39jsdc" ), any finite set of keys we choose will have holes. So we must give up on that.


The only other option is to give StrictA its own index signature. Something like:

type StrictA = { a: 0, [k: string]: undefined }; // error!
// Property 'a' of type '0' is not assignable to string index type 'undefined'

is invalid. It doesn't mean " a is 0 and everything else is undefined ". It means " a is 0 and everything is undefined ", which is a contradiction. In order to even have the index signature compile you need to make its property type at least as wide as the union of all known property types:

type StrictA = { a: 0, [k: string]: 0 };

But of course, this is not what we want either. It leads to the following behavior:

type U = StrictA | StrictB;
const badUnionAB: U = { a: 0, c: 1 }; // 0 is not assignable to 1 ✔
const badUnionAB1: U = { a: 0, c: 0 }; // ❌

While badUnionAB is now correctly prohibited, badUnionAB1 is erroneously allowed, even though it is not directly "strictly" assignable to either A or B .


So we're stuck. There is simply no way to make a union "strict" in this way if any of the union constituents have a string index signature.

In the case where you need something like this behavior, you'd have to give up on ExclusifyUnion<A | B> ExclusifyUnion<A | B> being a specific type, and instead would need to make it a generic constraint on a candidate type like ExclusifyUnion<A | B, T> ExclusifyUnion<A | B, T> which would evaluate to (say) T if T is "strictly" assignable to A | B A | B , and (say) never otherwise. (This is the same general approach as in this answer by @CraigHicks)

Then you could make helper functions like asStrictUnion() instead of using type annotations:

type ExclusifyUnion<U, T extends U> = U extends any ?
  [T] extends [U] ? keyof T extends keyof U ? T : never : never : never;
const asStrictUnion = <U>() => <T extends U>(t: ExclusifyUnion<U, T>) => t;

type A = { a: 0 };
type B = { [k: string]: 1; }

const asStrictU = asStrictUnion<A | B>();

const goodUnionA = asStrictU({ a: 0 }); // okay ✔
const goodUnionB = asStrictU({ a: 1, c: 1 }); // okay ✔
const badUnionAB = asStrictU({ a: 0, c: 1 }); // error ✔
const badUnionAB1 = asStrictU({ a: 0, c: 0 }); // error ✔

That works, but it's a fairly complicated approach which requires developers to remember to call a helper function. Or you could move this validation to whatever function accepts object literals from developers. But whether or not this is worth the effort is not clear to me. Especially because the lack of exact type support in TypeScript means that it's still possible for something to fall through the cracks here:

const z = { a: 0, z: 25 } as const; // no error
const a: A = z; // no error
asStrictU(a); // no error

It's up to you I guess.


Playground link to code

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM