You asked for it.
Let's do some type manipulation to detect if a given type is a union or not. The way it works is to use the distributive property of conditional types to spread out a union to constituents, and then notice that each constituent is narrower than the union. If that isn't true, it's because the union has only one constituent (so it isn't a union):
type IsAUnion<T, Y = true, N = false, U = T> = U extends any
? ([T] extends [U] ? N : Y)
: never;
Then use it to detect if a given string
type is a single string literal (so: not string
, not never
, and not a union):
type IsASingleStringLiteral<
T extends string,
Y = true,
N = false
> = string extends T ? N : [T] extends [never] ? N : IsAUnion<T, N, Y>;
Now we can start taking about your particular issue. Define BaseObject
as the part of ComboObject
that you can define straightforwardly:
type BaseObject = { known: boolean, field: number };
And preparing for error messages, let's define a ProperComboObject
so that when you mess up, the error gives some hint about what you were supposed to do:
interface ProperComboObject extends BaseObject {
'!!!ExactlyOneOtherStringPropertyNoMoreNoLess!!!': string
}
Here comes the main course. VerifyComboObject<C>
takes a type C
and returns it untouched if it conforms to your desired ComboObject
type; otherwise it returns ProperComboObject
(which it also won't conform to) for errors.
type VerifyComboObject<
C,
X extends string = Extract<Exclude<keyof C, keyof BaseObject>, string>
> = C extends BaseObject & Record<X, string>
? IsASingleStringLiteral<X, C, ProperComboObject>
: ProperComboObject;
It works by dissecting C
into BaseObject
and the remaining keys X
. If C
doesn't match BaseObject & Record<X, string>
, then you've failed, since that means it's either not a BaseObject
, or it is one with extra non-string
properties. Then, it makes sure that there is exactly one remaining key, by checking X
with IsASingleStringLiteral<X>
.
Now we make a helper function which requires that the input parameter match VerifyComboObject<C>
, and returns the input unchanged. It lets you catch mistakes early if you just want an object of the right type. Or you can use the signature to help make your own functions require the right type:
const asComboObject = <C>(x: C & VerifyComboObject<C>): C => x;
Let's test it out:
const okayComboObject = asComboObject({
known: true,
field: 123,
unknownName: 'value'
}); // okay
const wrongExtraKey = asComboObject({
known: true,
field: 123,
unknownName: 3
}); // error, '!!!ExactlyOneOtherStringPropertyNoMoreNoLess!!!' is missing
const missingExtraKey = asComboObject({
known: true,
field: 123
}); // error, '!!!ExactlyOneOtherStringPropertyNoMoreNoLess!!!' is missing
const tooManyExtraKeys = asComboObject({
known: true,
field: 123,
unknownName: 'value',
anAdditionalName: 'value'
}); // error, '!!!ExactlyOneOtherStringPropertyNoMoreNoLess!!!' is missing
The first one compiles, as desired. The last three fail for different reasons having to do with the number and type of extra properties. The error message is a little cryptic, but it's the best I can do.
You can see the code in action in the Playground.
Again, I don't think I recommend that for production code. I love playing with the type system, but this one feels particularly complicated and fragile, and I wouldn't want to feel responsible for any unforeseen consequences.
Hope it helps you. Good luck!
ComboObject
type (exactly one extra key with string property and no other properties) but it is horrendous and not something you'd want to use in any production code. If you're interested I can post it but I think you might want to pursue other more TypeScript-friendly options instead. β Stonemasonprops: { children: string[] | VDOMElement[] } & { [key: string]: string };
However, putting them together without the&
operator didn't work:props: { children: string[] | VDOMElement[], [key: string]: string };
β Anglian& {[key: string]: string}
approach that often doesn't work, since the known properties cannot be unioned with a string: typescriptlang.org/play?#code/… β Martynnetsconfig
. :) It's pretty strict. β Anglian