I have a big type:
type BigType = {
aaa: string,
bbb?: number,
ccc: boolean[],
extra?: {
[key in string]?: string
},
nested1: {
nested2: {
nested3: {
[key in string]?: string
}
}
}
}
And I want to define another type and make sure it's subset of the BigType
, so I defined a RecursivePartial
type:
type RecursivePartial<T> = {
[P in keyof T]?:
T[P] extends (infer U)[] ? RecursivePartial<U>[] :
T[P] extends object ? RecursivePartial<T[P]> :
T[P];
};
and
type PartOf<T, X extends RecursivePartial<T>> = X;
Now I can define a small type which is only part of the BigType:
type SmallType = PartOf<BigType, {
aaa: string;
extra: { ddd: string };
nested1: { nested2: {} }
}>
The problem is I can add properties which is not part of BigType
too:
type SmallType = PartOf<BigType, {
aaa: string;
extra: { ddd: string };
nested1: { nested2: {} },
someOtherProperty1: string, // not part of BigType
someOtherProperty2: string, // not part of BigType
}>
How to fix it?
The issue here is that object types in TypeScript are open and not exact (see #12936 for discussion about exact types). That is, you can object types A
and B
where A extends B
and A
has properties that B
doesn't mention. This is actually a crucial part of interface/class hierarchies; without it, you couldn't add properties to subinterfaces/subclasses. Still, there are times when it surprises people (especially because when you use object literal values the compiler performs additional excess property checking which makes it look like object types are exact).
Exact object types can't currently be represented in TypeScript as specific concrete types. Instead, you have to use generics (see this GitHub comment for more information)
Anyway in your case I'd probably proceed by defining a DeepPartial
and a DeepNoExcess
type alias and using both of them in TypeOf
. DeepPartial
looks like this:
type DeepPartial<T> = { [K in keyof T]?: DeepPartial<T[K]> };
This is essentially the same as your RecursivePartial
, I think. As of TS3.1, mapped types automatically map over arrays and tuples without needing special casing, and recursive mapped types that encounter primitive types leave them unmapped (see microsoft/TypeScript#12447). That means you don't need to do much to get a recursive Partial
.
DeepNoExcess
has to take both the main type and the candidate type (since it's not possible to represent exact types concretely):
type DeepNoExcess<T, U> = { [K in keyof U]:
K extends keyof T ? DeepNoExcess<Required<T>[K], U[K]> :
never };
This walks down through the properties of the candidate type U
and makes the property type never
if the property key doesn't also exist in T
. I had to walk down into Required<T>
instead of just T
because your optional properties weren't being handled properly (keyof (SomeType | undefined)
tends to be never
).
Then PartOf
is defined like this:
type PartOf<T, U extends DeepPartial<T> & DeepNoExcess<T, U>> = U;
This yields the behavior you are hoping for with your two examples:
type GoodSmallType = PartOf<BigType, {
aaa: string;
extra: { ddd: string };
nested1: { nested2: {} }
}>; // okay
type BadSmallType = PartOf<BigType, {
aaa: string;
extra: { ddd: string };
nested1: { nested2: {} },
someOtherProperty1: string, // not part of BigType
someOtherProperty2: string, // not part of BigType
}>; // error! Types of property 'someOtherProperty1' are incompatible.
Whether it meets all your use cases is not clear; there are lots of decisions you could make (like Required<T>
instead of T
) that will have implications for what types are accepted and what types are not. But hopefully this gives you a way forward at least. Good luck!