Reputation: 28880
I have created this playground with this code
export type Spec<E extends Element> = {
prop?: string;
getters?: Record<string, (element: E) => string>;
}
export class Thing<E extends Element, S extends Spec<E>> {
constructor(
public name: string,
private specification: S,
) {}
}
export type InteractorInstance<E extends Element, S extends Spec<E>> = Thing<E, S>;
export type GetterImplementation<E extends Element, S extends Spec<E>> = {
[P in keyof S['getters']]: S['getters'][P] extends ((element: E, ...args: unknown[]) => unknown) ? (value: string) => InteractorInstance<E, S> : never;
}
export type InteractorType<E extends Element, S extends Spec<E>> =
((value: string) => InteractorInstance<E, S>) &
GetterImplementation<E, S>;
export function createThing<E extends Element>(name: string) {
return function<S extends Spec<E>>(specification: S) {
const result = function(value: string): Thing<E, S> {
let thing = new Thing<E, S>(name, specification);
return thing;
}
return result as InteractorType<E, S>;
}
}
const Link = createThing<HTMLLinkElement>('link')({
prop: 'a',
getters: {
byThis: (element) => element.href,
byThat: (element) => element.title
},
whoCaresWhatThisIs: 666. // should not be here
});
// THis is why Spec needs to be a generic type argument
// I do some shenanigans in LocatorImplementation to add these props onto the Thing
Link.byThat('bb');
Link.byThis('cc')
Can I make the Spec
only have the keys of Spec
, i.e. anything other than prop
or getters
is invalid?
I need Spec to be a type argument because it is used in a conditional type in SpecImplementation
Upvotes: 1
Views: 240
Reputation: 4502
I made some change to your code in playground that I think solves your problem.
In your original code yuo had S extends Spec<E>
as an type argument you passed around. This meant that Type S
can allow extra properties.
However, by defining a Type argument T extends string
which you pass around, your function argument is of type Spec<E, T>
now instead of S extends Spec<E>
which means it can't allow extra properties.
I include the modified code here as well:
export type Spec<E extends Element, T extends string> = {
prop?: string;
getters?: {
[key in T]: (element: E) => string;
}
}
export class Thing<E extends Element, T extends string> {
constructor(
public name: string,
private specification: Spec<E, T>,
) {}
}
export type InteractorInstance<E extends Element, T extends string> = Thing<E, T>;
// GetterImplementation is simpler now.
export type GetterImplementation<E extends Element, T extends string> = {
[P in T]: (value: string) => InteractorInstance<E, T>;
}
export type InteractorType<E extends Element, T extends string> =
((value: string) => InteractorInstance<E, T>) &
GetterImplementation<E, T>;
export function createThing<E extends Element>(name: string) {
return function<T extends string>(specification: Spec<E, T>) {
const result = function(value: string): Thing<E, T> {
let thing = new Thing<E, T>(name, specification);
return thing;
}
return result as InteractorType<E, T>;
}
}
// Type T here is infered as 'byThis'|'byThat'
const Link = createThing<HTMLLinkElement>('link')({
prop: 'a',
getters: {
byThis: (element) => element.href,
byThat: (element) => element.title,
},
whoCaresWhatThisIs: 666. // This now gives an error as desired
});
// THis is why Spec needs to be a generic type argument
// I do some shenanigans in LocatorImplementation to add these props onto the Thing
Link.byThat('bb'); // This work as expected now
Link.byThis('cc'); // This work as expected now
// Since Link is of Type InteractorType<HTMLLinkElement, 'byThat'|'byThis'>, byThose does not work
Link.byThose('dd'); // This also results in an error
I hope my answer solves your problem, and thank you for the question. Thinking about the solution helped learn something new.
Upvotes: 1
Reputation: 4020
As far as I understand it, TS doesn't support excess property checks for generic parameter object literals. I think what you're looking for is exact types - something that's in discussion here: https://github.com/microsoft/TypeScript/issues/12936
What is possible is something a bit hacky on the basis that you can't assign a parameter which has a type never to a function. So here I check the generic to see if it meets the criteria, and then block if it isn't. The error message isn't the prettiest though.
type EnforceKeyMatch<I extends {[KI in keyof T]: any }, T extends {[TI in keyof T]?: any }> = keyof I extends keyof T ? keyof T extends keyof I ? I : never : never;
export function createThing<E extends Element>(name: string) {
return function<S extends Spec<E>>(specification: EnforceKeyMatch<S, Spec<Element>>) {
const result = function(value: string): Thing<E, S> {
let thing = new Thing<E, S>(name, specification);
return thing;
}
return result as InteractorType<E, S>;
}
}
With this I try to enforce that for the params to EnforceKeyMatch, the keys of I (input) extend the keys of T (target), and vice versa. This then spits out a never type if this condition is not met for specification.
So here with any extra property the type now errors:
But with the property removed it functions as normal:
Here is a link to the playground
Upvotes: 0
Reputation: 3939
As pointed out earlier, S extends Spec
allows extra properties - exactly what you want to avoid!
But let's see why you need to extend Spec exactly... Looks like you need you be able to provide a custom map of getters, while in Spec
it's defined as Record<string, Function>
. Ok - so isn't it the clue then? Let's make Spec itself generic, parameterized by the keys in this record:
export type Spec<E extends Element, G extends string> = {
prop?: string;
getters?: Record<G, (element: E) => string>;
}
Then we update all derived types correspondingly. Note that GetterImplementation
mapped type becomes simpler now, as it works directly with the new generic parameter:
export type GetterImplementation<E extends Element, G extends string> = {
[P in G]: (value: string) => InteractorInstance<E, G>;
}
(in fact I might have misunderstood the intention with this mapped type... if you do want smart handling of getters, it should be made a part of Spec
- currently Spec
doesn't allow to have a getter with extra arguments).
And finally, the function itself is gonna be generic with the new G extends string
generic parameter instead of Spec
:
export function createThing<E extends Element>(name: string) {
return function<G extends string>(specification: Spec<E, G>) {
const result = function(value: string): Thing<E, G> {
let thing = new Thing<E, G>(name, specification);
return thing;
}
return result as InteractorType<E, G>;
}
}
Upvotes: 3
Reputation: 1210
If Spec needs to be strongly typed, then why S extends Spec? I tried the following and the returned type didn't allow anything apart from props and getters,
export function createThing<E extends Element>(name: string) {
return function(specification: Spec<E>) {
const result = function(value: string): Thing<E, Spec<E>> {
let thing = new Thing<E, Spec<E>>(name, specification);
return thing;
}
return result;
}
}
Checkout my forked playground here: Playground Link
Upvotes: 1