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

Generic derived value type #28597

Open
jurajkocan opened this issue Nov 19, 2018 · 10 comments
Open

Generic derived value type #28597

jurajkocan opened this issue Nov 19, 2018 · 10 comments
Labels
Domain: Conditional Types The issue relates to conditional types Experience Enhancement Noncontroversial enhancements Suggestion An idea for TypeScript
Milestone

Comments

@jurajkocan
Copy link

Hey guys.
I can not see if I'm doing anything wrong here. I am expecting to be able to get exact type from generic type ...
Is this just limitation of typescript or i am doing anything whrong here?

TypeScript Version: 3.1.3

Search Terms:
Generic derived value type

Code

export type filterTypeName = 'price' | 'price_range';
export type priceFilterValue = {
  from: number;
  to: number;
};
export type priceRangeFilterValue = {
  fromRange: number;
  toRange: number;
};

export type filterValueType<T extends filterTypeName> =  
    T extends 'price' ? priceFilterValue
    : T extends 'price_range' ? priceRangeFilterValue : never;


type productFilterParameters<T extends filterTypeName> = {
  filter: T;
    value: filterValueType<T>;
};

function addProductFilter<T extends filterTypeName>(params: productFilterParameters<T>) { 
    switch (params.filter) { 
        case 'price':
            const priceFrom = params.value.from; //Property 'from' does not exist on type 'filterValueType<T>'    
            break;
        default:
            break;            
    }
}

// parameter value is derivered based on filter type... not working id function definition
addProductFilter({ filter: 'price', value: { from: 1, to: 2 } });

//expected behavior something like this

type productFilterParametersCorrect = {
    filter: 'price';
    value: priceFilterValue;
} | {
    filter: 'price_range';
    value: priceRangeFilterValue;
}

function addProductFilterCorrect(params: productFilterParametersCorrect) { 
    switch (params.filter) { 
        case 'price':            
            const priceFrom = params.value.from; // correct
            break;
        case 'price_range':
            const priceRangeFrom = params.value.fromRange; // correct
        default:
            break;            
    }
}

Expected behavior:
Code

const value: priceFilterValue

Actual behavior:
Code

const value: filterValueType<T>

Playground Link:
Playground

Related Issues:

@weswigham weswigham added Bug A bug in TypeScript Domain: Conditional Types The issue relates to conditional types labels Nov 19, 2018
@weswigham
Copy link
Member

@ahejlsberg it seems like we might be able to recognize discriminants in conditional types similarly to unions - though it also seems much less straightforward to resolve them.

@Nathan-Fenner
Copy link
Contributor

This isn't a bug (see this discussion in #24085 ).

Consider the following code:

type ExamplePrice = filterValueType<"price">;
// = { from: number; to: number; }

type ExampleRange = filterValueType<"price_range">;
// = { fromRange: number; toRange: number; }

type ExampleBoth = filterValueType<"price" | "price_range">;
// = priceFilterValue | priceRangeFilterValue

The last is because (for whatever reason), conditional types are automatically mapped over unions.

Therefore, I can make the following call to the function:

const weirdInput: productFilterParameters<"price" | "price_range"> = {
  filter: "price",
  value: {
    fromRange: 0,
    toRange: 10,
  },
}

addProductFilter<"price" | "price_range">(weirdInput);

with no errors. Everything type checks, as it's supposed to. Inside the function, the case will match "price", and yet there will be a fromRange and toRange field but no from and no to.

I have a proposal at #27808 to add an T extends_oneof("price", "price_range") constraint which would make the above code work as expected.

@jurajkocan
Copy link
Author

@weswigham so is this possible to implement in to the typescript or should i find another solution?
if so i will just wait for it.

@andyrichardson
Copy link

I've just come across this issue myself. Here's a quick reproduction.

type state = 'a' | 'b' | 'c';

interface MyInterface<S extends state> {
  state: S,
  additionalProp: S extends 'a' ? string[] : never;
}

const myFun = <S extends state>(arg: MyInterface<S>) => {
  // This infer arg as MyInterface<'c'>  
  if (arg.state === 'c') {
    return arg.additionalProp.length; // This should throw "cannot use property 'length' on type never"
  }

  // Manually casting this type works as expected
  const forcedType: MyInterface<'b'> = { state: 'b' };
  forcedType.additionalProp.length // error
}

It looks as though, despite the conditional statement, the type of arg.state remains to be undetermined.

@Nathan-Fenner
Copy link
Contributor

@andyrichardson that behavior is as expected (although for a completely different reason).

All operations are legal on never since it's a bottom type. It's impossible to have a value of type never (at least without cheating), so if you do get one, the impossible has already happened, and therefore there's nothing wrong with letting you do "impossible" operations.

Since .length is valid on both never and string[] there's no problem.

TypeScript does provide a best-effort check for operations on definitely never types, since those are likely mistakes. But for a value that's only maybe never it would be counterproductive to error.

Replacing never with null will give you an error, as expected.

@andyrichardson
Copy link

andyrichardson commented May 3, 2019

@Nathan-Fenner this isn't an issue relating to the use of null. If you replace, from the example, never with null you will see that an error is still not produced.

The problem is that following the condition if (arg.state === 'c') { the type of arg within the conditional body should be MyInterface<'c'> (and therefore additional prop is of type never). This isn't the case as the type remains to be unknown and therefore the type of arg.additionalProp remains to be undetermined (string[] | never or string[] | null as with your suggestion).

@Nathan-Fenner
Copy link
Contributor

The error absolutely is produced (provided that --strictNullChecks is on:

See playground for example

You obtain the error:

Object is possibly null

As expected. Your issue is due to misuse of never, not a bug in conditional types. Even then, it would be unrelated to the original issue here.

@andyrichardson
Copy link

@Nathan-Fenner you're right, the error does appear when strictNullChecks are enabled - but that doesn't solve the bug in question.

The problem is that following the condition if (arg.state === 'c') { the type of arg within the conditional body should be MyInterface<'c'> (and therefore additional prop is of type never).

The reason strict null checks throws an error is because the type of arg.additionalProp is string[] | null. It should be of type null given arg has the type of MyInterface<'c'>.

Type following conditional (incorrectly inferred)

image

Type when explicitly stated

image

@Nathan-Fenner
Copy link
Contributor

@andyrichardson The behavior is still as expected:

The reason strict null checks throws an error is because the type of arg.additionalProp is string[] | null. It should be of type null given arg has the type of MyInterface<'c'>

This isn't the case. Given that state === 'c', you only know that 'c' is a subtype of S. In particular, (e.g.)S = 'a' | 'c' is still entirely possible:

const exampleValue: MyInterface<'a' | 'c'> = {
  state: 'c',
  additionalProp: ["foo", "bar", "baz"],
};

This value can be passed to myFun above; you'll find that state === 'c' as expected, and also that additionalProp has type string[], not null. This works since conditional types are distributive, meaning that X | Y extends T ? A : B is really the same as (X extends T ? A : B) | (Y extends T ? A : B). This means that in the above, additionalProp really does have the type string[] | null, since 'a' follows the left path and 'c' follows the right one.

You can avoid distribution with the following trick:

interface MyInterface<S extends state> {
  state: S,
  additionalProp: [S] extends ['a'] ? string[] : never;
}

by wrapping the two types in a single-element tuple. However, this won't actually solve the problem, because it is still impossible to distinguish (solely using flow types) that S is actually 'c' and not 'a' | 'c' or 'a' | 'b' | 'c' or 'b' | 'c' (since you can only learn what it is allowed to be, and not what it is not allowed to be, from runtime checks).

The following trick almost works, except the TS's flow-typing is not sophisticated enough to understand that deducing S = 'c' is actually correct:

type state = 'a' | 'b' | 'c';

interface MyInterface<S extends state> {
  state: [S] extends ['a'] ? 'a' : [S] extends ['b'] ? 'b' : [S] extends ['c'] ? 'c' : null,
  additionalProp: [S] extends ['a'] ? string[] : null;
}

This makes the code actually sound (since the exampleValue above will no longer compile) since it enforces that the tag is consistently a singleton throughout the type, although you'll need to perform a cast inside the function to convince tsc that it really behaves as intended.

@weswigham weswigham added Experience Enhancement Noncontroversial enhancements and removed Bug A bug in TypeScript labels Jul 15, 2019
@agalazis
Copy link

agalazis commented Jul 21, 2019

Hello I was facing the same issue in my action factory (I might come back later with a minimal example). I think we need to come up with a way to declare the type as a Singletton or accept that it is singleton if it extends a singleton. In my case, it would be even more useful to allow a generic of singleton types eg ActionType<'my feature', 'my action type','my action name'>( being the type of 'myfeature | my action | my action name'
(I was trying to achieve something like #12754 (comment) with the issue being that the resulting type couldn't be used as a discriminant)

@RyanCavanaugh RyanCavanaugh added the Suggestion An idea for TypeScript label Jul 29, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Domain: Conditional Types The issue relates to conditional types Experience Enhancement Noncontroversial enhancements Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

8 participants