microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
101.33k stars 12.53k forks source link

Void parameter still required when type extends generic #29131

Open dested opened 5 years ago

dested commented 5 years ago

TypeScript Version: 3.2.2

Search Terms: void parameter type extends generic Code

function works1(n: number, b: void) { }

works1(12);

function works2(n: number, b: 1 extends 1 ? void : number) { }

works2(12);

function fails<T>(n: number, b: T extends 1 ? void : number) { }

fails<2>(12, 2);  // works, requires both params
fails<1>(12);  // fails, requires 2 parameters even though second param is void

Expected behavior: That I can ignore the second parameter since its void

Actual behavior: Ts tells me I am missing a parameter

https://www.typescriptlang.org/play/index.html#src=function%20works1(n%3A%20number%2C%20b%3A%20void)%20%7B%20%7D%0D%0A%0D%0Aworks1(12)%3B%0D%0A%0D%0Afunction%20works2(n%3A%20number%2C%20b%3A%201%20extends%201%3F%20void%20%3A%20number)%20%7B%20%7D%0D%0A%0D%0Aworks2(12)%3B%0D%0A%0D%0Afunction%20fails%3CT%3E(n%3A%20number%2C%20b%3A%20T%20extends%201%3Fvoid%3Anumber)%20%7B%20%7D%0D%0A%0D%0Afails%3C2%3E(12%2C2)%3B%20%20%2F%2Fworks%2C%20requires%20both%20params%0D%0Afails%3C1%3E(12)%3B%20%20%2F%2F%20requires%202%20parameters%20even%20though%20second%20param%20is%20void%0D%0A

jack-williams commented 5 years ago

This is a design limitation in the way the checks are implemented.

Checking for void parameters that can be elided is done prior to generic instantiation, which means that instantiations that produce void parameters are ignored, as is the case in fails.

This choice was made to avoid having to check every generic signature twice, and there were some issues with this breaking existing overload selections.

I would be interested in understanding real-word examples of this behaviour. Currently I'm not sure whether this behaviour can be implemented using existing methods (overloads), or whether we really need to extend the void checks.

dested commented 5 years ago

It was a poor mans method overloading, it just seemed very strange to me that it would not work even though the compiled method signatures were exactly the same. Now that I know when the void eliding is done I was able to solve it using the following:

function works3<T extends 1>(n: number, b: void)
function works3<T>(n: number, b: number)
function works3<T>(n: number, b: number | void) { }

works3<2>(12,2);
works3<1>(12);

Thanks for your help in understanding the situation! Feel free to close this if you see no reason to fix this as (at least in this case) it can be avoided.

jack-williams commented 5 years ago

Thanks for your help in understanding the situation! Feel free to close this if you see no reason to fix this as (at least in this case) it can be avoided.

It's not for me to open/close issues, that's for the team to decide. I would suggest leaving this open as the canonical thread that tracks use-cases that need the generic example to work too. If enough people find it useful then I'd be happy to add it.

lifaon74 commented 5 years ago

@jack-williams As you asked for an example:

class MyIterator<T> implements Iterator<T> {
  protected _value: T;
  constructor(value: T) {
    this._value = value;
  }

  next(): IteratorResult<T> {
    return {
      done: false,
      value: this._value
    };
  }

  foo(value: T): void {
  }
}

const it: MyIterator<void> = new MyIterator<void>(); // error !
it.next();
it.next();
/// ...
it.foo(); // valid !

It's simply an iterator that send everytime the same value provided in its constructor.

What's the strangest: the 'foo' is perfectly valid but the 'constructor' errors.

jack-williams commented 5 years ago

Thanks for the example!

What's the strangest: the 'foo' is perfectly valid but the 'constructor' errors.

The constructor is generic at the call-site; foo is concrete at the call-site because T is known to be void.

As a workaround you can do:

function init(x: void) {
    return new MyIterator(x);
}

const it: MyIterator<void> = init();
it.next();
it.next();
/// ...
it.foo();
parzh commented 4 years ago

Variable number of function parameters can still be achieved using tuple types (although, parameter identifiers in IntelliSense will be lost unfortunately):

declare function fn<Value extends 1 | 2>(...args: Value extends 1 ? [number] : [number, string]): void;

fn<1>(42);
fn<1>(42, "hello world"); // Error: expects 1 argument

fn<2>(42); // Error: expects 2 arguments
fn<2>(42, "hello world");

Link to playground.

dragomirtitian commented 4 years ago

@parzh You can get the parameter names back (mostly) if you don't mind writing a bit more:

declare function fn<Value extends 1 | 2>(...args: Value extends 1 ?  Parameters<(x: number) =>void> : Parameters<(y:number, name: string) => void>): void;

fn<1>(42);
fn<1>(42, "hello world"); // Error: expects 1 argument

fn<2>(42); // Error: expects 2 arguments
fn<2>(42, "hello world");

Playground Link

neopostmodern commented 4 years ago

I have a case where I'm implicitly overloading a function, but can't do explicit overloading due to maintaining closure. I'm trying to generically create Redux actions. The load action can have a parameter of a specifiable type, but should possibly have no parameters.

type DataStateActions<T, U> = {
  load: (parameters: U) => DataActionLoad<T, U>
  // omitted
}

const generateDataStateActions = <T, U = void>(
  type: string,
): DataStateActions<T, U> => ({
  load: (parameters): DataActionLoad<T, U> => ({
    type,
    payload: { intent: DataActionTypes.LOAD, parameters },
  }),
  // omitted
})

But then it fails at

type AiImplementationManagerContainerFunctionProps = {
  fetchCaseSetList: () => void
}
const mapDispatchToProps: AiImplementationManagerContainerFunctionProps = {
  fetchCaseSetList: caseSetListDataActions.load,
}

with

Error:(120, 3) TS2322: Type '(parameters: void) => DataActionLoad<CaseSetInfo[], void>' is not assignable to type '() => void'.

(Notice that the differing return types are completely fine – it's about the parameters: void part)

Playground

dragomirtitian commented 4 years ago

@neopostmodern I think the void parameters is not required really be longs in the not deprecated but don't use category of TS features (but keep in mind I an just a random guy on the internet with an opinion πŸ˜‹) . I think today, we can do much better with tuples in rest parameters (which did not exist when the void trick was implemented:

export enum DataActionTypes {
    LOAD = 'LOAD',
    // omitted
}

export type DataActionLoad<T, U  extends [undefined?] | [any] > = {
  type: string
  payload: { intent: DataActionTypes.LOAD; parameters: U[0] }
}

type DataStateActions<T, U  extends [undefined?] | [any]> = {
  load: (...parameters: U) => DataActionLoad<T, U>
  // omitted
}

const generateDataStateActions = <T, U  extends [undefined?] | [any]>(
  type: string,
): DataStateActions<T, U> => ({
  load: (...parameters: U): DataActionLoad<T, U> => ({
    type,
    payload: { intent: DataActionTypes.LOAD, parameters: parameters[0] },
  }),
  // omitted
})

const caseSetListDataActions = generateDataStateActions<number, []>('TYPE_NAME')

type AiImplementationManagerContainerFunctionProps = {
  fetchCaseSetList: () => void
}
const mapDispatchToProps: AiImplementationManagerContainerFunctionProps = {
  fetchCaseSetList: caseSetListDataActions.load,
}

Playground Link

I intentionally made this accept one parameter to keep the same API as your original, but with this approach, you could accept a variable number of parameter.

neopostmodern commented 4 years ago

I appreciate the flexibility this offers, but IMHO using void in this scenario should still work (and even seems preferable to me), for several reasons:

TL;DR Yes, alternatives exist – but void parameters should work too.

ATheCoder commented 4 years ago

JSDocs don't work either using tuple types.

PietroCarrara commented 1 year ago

This feature would really help the following use-case:

Our react codebase has some route objects, of type Route<UrlData, ComponentData>, which describes the data needed do navigate to that route. It is very nice to be able to type-safely navigate around the web-app like this:

navigate(routes.createItem, { urlProps: { ... }, componentProps: { ... } }) // You can't illegally navigate now!

Some pages are very simple, and they don't need any data. In this case, the type of the second argument of navigate will expand to { urlProps?: undefined, componentProps?: undefined }. This means you navigate to them via navigate(routes.welcomePage, {}).

What we wish we could do was transform that type into void so that you can't pass any data into the page, like this:

type MakeForbiddenArgument<T> = T[Exclude<keyof T, undefined>] extends undefined
  ? void
  : T;

navigate(routes.createItem); // This should work!

So, yeah, another interesting use-case for this.

allisonkarlitskaya commented 4 months ago

Came across this recently myself while trying to think up a workaround for #58977.

Here's a few more examples of weirdness, and an interesting 'escape hatch' from the problem via subclassing.

// This is equivalent to 'void', but tsc treats it weirdly
type AlwaysVoid<P> = P extends string ? void : void;

function simple_void(_x: void): void { }
function weird_void<P>(_x: AlwaysVoid<P>): void { }

class VoidCtor<P> { constructor(x: AlwaysVoid<P>) { use(x) } }
class VoidCtorSubclass extends VoidCtor<unknown> { }

function test_void_weirdness() {
    const void_value = (() => {})(); // get a value of type 'void'

    // This works just fine
    simple_void();

    // @ts-expect-error But this doesn't work
    weird_void();
    weird_void(void_value); // but we can demonstrate that `void` is indeed accepted here

    // @ts-expect-error This doesn't work
    new VoidCtor<unknown>();
    new VoidCtor<unknown>(void_value); // ... even though 'void' is the argument type

    // ...but for some reason this works, even though it's the very same function
    new VoidCtorSubclass();
}
RPGillespie6 commented 3 months ago

Another real use case is something like openapi-fetch

Where you want to be able to make the request body optional in some cases:

await client.GET("/store/order/{orderId}", { params: { path: { orderId: 4 } } }) // second param mandatory
await client.GET("/user/login") // second param optional

This could be achieved with a generics lookup with something like:

type initLookup = {
    "/store/order/{orderId}": StoreOrderComponent,
    "/user/login": void | RequestInit
}

...except this issue prevents it.

And you might say: "Just use overloads" - and while that does work from a typechecking perspective, for some reason VSCode just does not play nice with overloads and will not give you intelligent auto-complete, if typing, for example:

await client.GET("/store/order/{orderId}", // pressing ctrl+space here results in broken intellisense; VSCode seems unable to deduce which overload it should use

Maybe long term overloads are the correct solution, if VSCode could fix intellisense for overloaded TS functions.

JelleRoets-Work commented 2 weeks ago

I also faced similar issues when trying to make function arguments optional bases on context via generics. When playing around I found out the current ts behaviour isn't really consistent:

const f1 = (arg: void) => {};
const r1 = f1(); // no error

const f2 = <T = void>(arg: T) => {};
const r2 = f2(); // Expected 1 arguments, but got 0, An argument for 'arg' was not provided.

type F<T = void> = (arg: T) => void;
const f3: F = arg => {};
const r3 = f3(); // no error

const f4 = <T = undefined>(arg: T extends undefined ? void : T) => {};
const r4 = f4<undefined>(); // Expected 1 arguments, but got 0, An argument for 'arg' was not provided.

class Klass<T = void> {
  constructor(arg: T) {}
  f(arg: T) {
    return;
  }
}
const k = new Klass(); // Expected 1 arguments, but got 0, An argument for 'arg' was not provided.
const r5 = k.f(); // no error

Playground Link

The reasoning in https://github.com/microsoft/TypeScript/issues/29131#issuecomment-449634318 kinda explains the above (inconsistent) behaviour. But from a ts user perspective I think it would still make sense to have the ability to conditionally make function arguments optional.