Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Conditional type using never type does not work in generic functions #54806

Closed
dummdidumm opened this issue Jun 28, 2023 · 9 comments
Closed
Labels
Not a Defect This behavior is one of several equally-correct options

Comments

@dummdidumm
Copy link

Bug Report

When using a conditional type that checks for the never type inside a function with a generic parameter that check is not correctly executed and the type is not narrowed accordingly, even if the generic has a constraint that makes clear that the generic type can't be never (though it should probably also work without a constraint).

🔎 Search Terms

generic function narrow condition type never

Possibly related to #49852 / #53455

🕗 Version & Regression Information

This is the behavior in every version I tried, and I reviewed the FAQ for entries about generic functions and never type

⏯ Playground Link

Playground link with relevant code

💻 Code

type IsNever<Type> = [Type] extends [never] ? undefined : string;
type IsBoolean<Type> = Type extends boolean ? string : undefined;

declare function neverFn<T>(t: T): IsNever<T>;
declare function booleanFn<T>(t: T): IsBoolean<T>;

function genericFn<T extends boolean>(t: T) {
	const booleanResult = booleanFn(t);
	booleanResult.toString() // works: booleanResult is correctly narrowed to type string

	const neverResultFromLiteral = neverFn(true);
	neverResultFromLiteral.toString(); // works: neverResultFromLiteral is correctly narrowed to type string

	const neverResultFromGeneric = neverFn(t);
	neverResultFromGeneric.toString(); // error: neverResultFromGeneric is not correctly narrowed to type string
}

🙁 Actual behavior

neverResultFromGeneric is not correctly narrowed to type string

🙂 Expected behavior

neverResultFromGeneric is correctly narrowed to type string

@jcalz
Copy link
Contributor

jcalz commented Jun 28, 2023

I'm not seeing a bug here.

even if the generic has a constraint that makes clear that the generic type can't be never

But never is the bottom type in TypeScript: for all X, never extends X. One cannot use a generic constraint to prohibit never. So genericFn<never> is a valid instantiation. Could you elaborate on where you think there's a bug?

@dummdidumm
Copy link
Author

The use case here is to type that a function should never accept a second parameter under certain cirumstances:

// type definition
interface EventDispatcher<EventMap extends Record<string, any>> {
	<Type extends keyof EventMap>(
		...args: [EventMap[Type]] extends [never]
			? [type: Type, parameter?: null | undefined]
			: null extends EventMap[Type]
			? [type: Type, parameter?: EventMap[Type]]
			: undefined extends EventMap[Type]
			? [type: Type, parameter?: EventMap[Type]]
			: [type: Type, parameter: EventMap[Type]]
	): boolean;
}

// function definition
declare function createEventDispatcher<EventMap extends Record<string, any> = any>(): EventDispatcher<EventMap>;

// usage
const dispatch = createEventDispatcher<{
   requiredParam: string;
   noParam: never;
   optionalParam: string | null;
}>();

dispatch('requiredParam'); // error, no second param given
dispatch('noParam', 'foo'); // error, second param given

I guess I need to rewrite the type to use noParam: null for that case then - this luckily works in this case. I'm curious though - assuming that wouldn't work for me, how should one be able to "ban" never from a generic then to get around this? Not possible, because it's a type system limitation?

@fatcerberus
Copy link

fatcerberus commented Jun 28, 2023

It's actually a fundamental type theoretical limitation - the bottom type never is assignable to all types, just as unknown is assignable from all types. This is related to the logical principle of explosion: if you "have a value of type never"--which has no values--then that's a logical contradiction and you can thus "prove anything from a contradiction." Of course this is just the theory; the practical idea it corresponds to is, any program state where the code obtains such a value must be unreachable by definition, so it's impossible to prove or disprove that its type is correct (because type checking it at all presumes it's reachable and thus invokes the contradiction).

@RyanCavanaugh
Copy link
Member

The definition of neverFn is the root cause here. What you're saying is that if you call this function, then it returns a string if the value isn't never. But if you have a value of type never, then you can't be there in the first place. So this function is the same as

declare function neverFn<T>(t: T): string;

and you're just making it much more confusing to reason about by saying IsNever<T> instead. There's no reason to do that.

@RyanCavanaugh RyanCavanaugh added the Not a Defect This behavior is one of several equally-correct options label Jun 28, 2023
@dummdidumm
Copy link
Author

Apologies, I should've made the use case more clear - this is only what I reduced this to. The actual use case can be found in this playground.

Essentially the task is to give the user some way to specify that a function when called with a specific string as the first parameter only accepts undefined or null as the second parameter (the parameter shouldn't be never so that you can actually pass something to it to fill the third parameter) - and the conditional should work for both with and without strict null types. But I guess never is not the right type to formulate this.

@RyanCavanaugh
Copy link
Member

That doesn't seem incorrect either. If we follow the rule-of-thumb that a generic body should work for any valid instantiation of its type parameters, the function's error is correct:

// inside a function
function fn<T extends boolean>(t: T) {
	const dispatch = createEventDispatcher<{
   		requiredParam: T;
   		noParam: never;
   		optionalParam: string | null;
	}>();

	dispatch('requiredParam', true);
	// Modified to expose
	return dispatch;
}

// Sample instantiation
const a = fn(false);
// It's not allowed.
a('requiredParam', true);

@dummdidumm
Copy link
Author

I now understand that this isn't incorrect because T extends boolean -> T could be never never extends boolean == valid -> can't be narrowed.

I guess at this point this deviated into a question rather than an issue, so I'm going to close this - thanks for the explanation everyone. What I take from this is that you should be very careful where you use never as it quickly breaks down in (at first) unexpected ways when used with generic types that can't be narrowed (in other words, inside the function that defines the generic).

@fatcerberus
Copy link

fatcerberus commented Jun 28, 2023

@dummdidumm A good way to think about it is: “I have a value of type never” -> “I’ve already thrown an error/crashed”. If you haven’t, then that’s a type violation in itself. So you shouldn’t really worry about people passing nevers to your functions.

@dummdidumm
Copy link
Author

That is true - and I also shouldn't use/encourage never as way for people to type that for example a parameter does not exist (as I did in this case), as that opens the path to this type violation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Not a Defect This behavior is one of several equally-correct options
Projects
None yet
Development

No branches or pull requests

4 participants