dagda1
dagda1

Reputation: 28880

type widening on generic parameter losing type safety

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

Answers (4)

Sherif Elmetainy
Sherif Elmetainy

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

mbdavis
mbdavis

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:

enter image description here

But with the property removed it functions as normal:

enter image description here

Here is a link to the playground

Upvotes: 0

amakhrov
amakhrov

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>;
  }
}

Playground

Upvotes: 3

Santhos Ramalingam
Santhos Ramalingam

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

Related Questions