Welcome to OGeek Q&A Community for programmer and developer-Open, Learning and Share
Welcome To Ask or Share your Answers For Others

Categories

0 votes
762 views
in Technique[技术] by (71.8m points)

typescript - Unexpected behaviour using Index type with Union type signature

I have a data structure where one of the keys allows for a dynamic set of values. I know the potential type of these values but I am unable to express this in Typescript.

interface KnownDynamicType {
    propA: boolean;
}

interface OtherKnownDynamicType {
    propB: number;
}

// I want to allow dynamic to accept either KnownDynamicType, OtherKnownDynamicType or a string as a value
interface DataModel {
    name: string;
    dynamic: {[key: string]: KnownDynamicType | OtherKnownDynamicType | string};
}

const data: DataModel = { // Set up some values
    name: 'My Data Model',
    dynamic: {
        someKnownType: {
            propA: true
        },
        someOtherKnownType: {
            propB: 1
        },
        someField1: 'foo',
        someField2: 'bar'
    }
}

data.dynamic.foo = 'bar'; // Fine
data.dynamic.someObject = { // <--- This should be invalid
    propA: false,
    propB: ''
}

Typescript seems to see data.dynamic.someObject as KnownDynamicType but unexpectedly allows me to assign properties from OtherKnownDynamicType to it as well.

I've done my best to avoid complex typing but when a situation arrises, I rather avoid throwing in the towel and just setting dynamic: any

My question is, how can I properly express the above in Typescript?

See Question&Answers more detail:os

与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome To Ask or Share your Answers For Others

1 Reply

0 votes
by (71.8m points)

It is true that a union is inclusive, so A | B includes anything which is a valid A, and anything which is a valid B, including anything which is a valid A & B. TypeScript doesn't have negated types, so you can't say something like (A | B) & !(A & B), which would explicitly rule out anything which matches both A and B. ?? Oh well.

TypeScript also lacks exact types, so adding a property to a valid A will still result in a valid A. So you can't say Exact<A> | Exact<B>, which would also have the effect of ruling out anything which matches both A and B, as well as anything with any property other than the explicitly declared properties of either A or B. ?? Oh well.

TypeScript does have excess property checking, which provides some of the benefits of negated or exact types by disallowing extra properties on "fresh" object literals. Unfortunately, there is (as of TypeScript v2.8 v3.5) an issue where excess property checking on union types isn't as strict as most people would like, as you've discovered. It looks like the issue is considered a bug and should be addressed in TypeScript v3.0 (edit:) the future, but that doesn't solve your problem today. ?? Oh well.


So, maybe we can get fancy with our types to get something similar to the behavior you want:

type ProhibitKeys<K extends keyof any> = { [P in K]?: never }    

type Xor<T, U> = (T & ProhibitKeys<Exclude<keyof U, keyof T>>) | 
  (U & ProhibitKeys<Exclude<keyof T, keyof U>>);

Let's examine those. ProhibitKeys<K> returns a type with all optional properties whose keys are in K and whose values are of type never. So ProhibitKeys<"foo" | "bar"> is equivalent to {foo?: never, bar?: never}. Since never is an impossible type, the only way for a real object to match ProhibitKeys<K> is for it not to have any properties whose keys are in K. Hence the name.

Now let's look at Xor<T,U>. The type Exclude<keyof A, keyof B> is a union of all the declared keys in A that do not also appear as keys in B. Xor<T,U> is a union of two types. The first one is T & ProhibitKeys<Exclude<keyof U, keyof T>>, which means it is a valid T with no properties from U (unless those properties are also in T). And the second is U & ProhibitKeys<Exclude<keyof T, keyof U>>, which means a valid U with no properties from T. This is as close as I can get to expressing the idea of the exclusive union you want in TypeScript. Let's use it and see if it works.

First, change DataModel:

interface DataModel {
  name: string;
  dynamic: { [key: string]: Xor<KnownDynamicType, OtherKnownDynamicType> | string };
}

And let's see what happens:

declare const data: DataModel;
data.dynamic.foo = 'bar'; // okay

data.dynamic.someObject = { 
  propA: false,
  propB: 0
}; // error!  propB is incompatible; number is not undefined.

data.dynamic.someObject = {
  propA: false
}; // okay now

data.dynamic.someObject = {
  propB: 0
}; // okay now

data.dynamic.someObject = { 
  propA: false,
  propC: 0  // error! unknown property
}

That all works as expected. ?? Hope that helps. Good luck!


与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
OGeek|极客中国-欢迎来到极客的世界,一个免费开放的程序员编程交流平台!开放,进步,分享!让技术改变生活,让极客改变未来! Welcome to OGeek Q&A Community for programmer and developer-Open, Learning and Share
Click Here to Ask a Question

...