Skip to content

Conditional types don't retain type knowledge #29599

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

Closed
kjleitz opened this issue Jan 26, 2019 · 5 comments
Closed

Conditional types don't retain type knowledge #29599

kjleitz opened this issue Jan 26, 2019 · 5 comments
Labels
Question An issue which isn't directly actionable in code

Comments

@kjleitz
Copy link

kjleitz commented Jan 26, 2019

TypeScript Version: 3.2.4

Search Terms: "conditional types don't", "generic type keeps", "conditional type retain", "conditional type any", "conditional type interface", "conditional type infer"

Code

interface Foo<T = any> {
  bar: T extends number ? T : boolean;
}

interface Bar<T = any> {
  bar: T extends number ? number : boolean;
}

const qwe: Foo = { bar: 12 };
const uio: Foo = { bar: false };
const rty: Foo = { bar: 'blah' };

const asd: Bar = { bar: 12 };
const jkl: Bar = { bar: false };
const fgh: Bar = { bar: 'blah' };

Expected behavior:

interface Foo<T = any> {
  bar: T extends number ? T : boolean;
}

interface Bar<T = any> {
  bar: T extends number ? number : boolean;
}

const qwe: Foo = { bar: 12 };     // good
const uio: Foo = { bar: false };  // good
const rty: Foo = { bar: 'blah' }; // good

const asd: Bar = { bar: 12 };     // good
const jkl: Bar = { bar: false };  // good
const fgh: Bar = { bar: 'blah' }; // good

Actual behavior:

interface Foo<T = any> {
  bar: T extends number ? T : boolean;
}

interface Bar<T = any> {
  bar: T extends number ? number : boolean;
}

const qwe: Foo = { bar: 12 };     // good
const uio: Foo = { bar: false };  // good
const rty: Foo = { bar: 'blah' }; // good

const asd: Bar = { bar: 12 };     // good
const jkl: Bar = { bar: false };  // good
const fgh: Bar = { bar: 'blah' }; // Type '{ bar: string; }' is not assignable to type 'Bar<any>'.
                                  // Types of property 'bar' are incompatible.
                                  // Type 'string' is not assignable to type 'number | boolean'.

Playground Link: https://www.typescriptlang.org/play/#src=%0D%0Ainterface%20Foo%3CT%20%3D%20any%3E%20%7B%0D%0A%20%20bar%3A%20T%20extends%20number%20%3F%20T%20%3A%20boolean%3B%0D%0A%7D%0D%0A%0D%0Ainterface%20Bar%3CT%20%3D%20any%3E%20%7B%0D%0A%20%20bar%3A%20T%20extends%20number%20%3F%20number%20%3A%20boolean%3B%0D%0A%7D%0D%0A%0D%0Aconst%20qwe%3A%20Foo%20%3D%20%7B%20bar%3A%2012%20%7D%3B%0D%0Aconst%20uio%3A%20Foo%20%3D%20%7B%20bar%3A%20false%20%7D%3B%0D%0Aconst%20rty%3A%20Foo%20%3D%20%7B%20bar%3A%20'blah'%20%7D%3B%0D%0A%0D%0Aconst%20asd%3A%20Bar%20%3D%20%7B%20bar%3A%2012%20%7D%3B%0D%0Aconst%20jkl%3A%20Bar%20%3D%20%7B%20bar%3A%20false%20%7D%3B%0D%0Aconst%20fgh%3A%20Bar%20%3D%20%7B%20bar%3A%20'blah'%20%7D%3B

Related Issues: Not that I can tell!

tsconfig.json:

{
  "compilerOptions": {
    "declaration": false,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "lib": ["es7", "dom"],
    "module": "es2015",
    "moduleResolution": "node",
    "sourceMap": true,
    "target": "es5",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "baseUrl": "./app/javascript",
    "noImplicitThis": true
  },
  "include": [
    "app/javascript/**/*.ts",
    "app/javascript/**/*.tsx",
    "app/javascript/**/*.vue"
  ],
  "exclude": [
    "**/*.spec.ts",
    "node_modules"
  ],
  "compileOnSave": false
}
@jack-williams
Copy link
Collaborator

I think this is working as intended. I'm not entirely sure what you mean by:

Conditional types don't retain type knowledge

IMO, it is much easier to give feedback if you also include why you think this is wrong, in addition to what went wrong---this way feedback can be directly tailored to your concerns.

A few things to note in the code:

In all cases the generic parameter T is being instantiated to any, so the check type in each of your conditional types is any. (In A extends B ? T : U, I call A the check type, and B the extends type)

The any type has special behaviour when used as a check type, which is because the any type is like a wildcard on types---it could represent any possible type. When the check type is any, then the conditional types picks both branches (except when the extends types is also any).

So in the Foo case, the property of bar has the following type.

  • any extends number ? any : boolean that simplifies to any | boolean that simplifies to any.

Consequently, you get no errors at the assignment.

In the Bar case, the property bar has the following type.

  • any extends number ? number : boolean that simplifies to number | boolean.

Consequently, you get an error when assigning a string value to the parameter.

@fatcerberus
Copy link

This is neither here nor there but the idea of a conditional type taking both branches and making a union out of that is... kind of mind-blowing to me for some reason (but makes perfect sense in hindsight). Does it do the same for unknown?

@jack-williams
Copy link
Collaborator

@fatcerberus

Does it do the same for unknown?

It does not. There is some relevant discussion here: #27418

@kjleitz
Copy link
Author

kjleitz commented Jan 28, 2019

Ahhh, you explained it perfectly @jack-williams. That's what I get for late-night issue submission. I think what I was trying to do (and failing to figure out how) was to have the generic type T infer its value based on the type of one of the interface's properties. Something like:

interface BoolProp</* some way of getting a lazy T */> {
  propType: T /* inferred somehow? */;
  prop: T extends FunctionConstructor ? (() => boolean) : boolean;
}

// pass
const foo: BoolProp = {
  propType: Function,
  prop: () => true,
}

// fail
const bar: BoolProp = {
  propType: Function,
  prop: true,
}

// pass
const baz: BoolProp = {
  propType: Boolean,
  prop: true,
}

// fail
const xyz: BoolProp = {
  propType: Boolean,
  prop: () => true,
}

That's a contrived/simplified example of what I'm trying to accomplish, but I think it illustrates the issue.

@jack-williams
Copy link
Collaborator

I'm not sure what you want can be exactly achieved; specifically, the bit where you omit the type parameter in the definition of BoolProp and have it lazily inferred. This isn't an assertion; if anyone knows a way to achieve this then I'd be really interested.

Off the top of my head I can think of three possible routes forward.

A) Hope that partial type inference is merged (#26349) so you can do something like this:

interface BoolProp<T> {
  propType: T;
  prop: T extends FunctionConstructor ? (() => boolean) : boolean;
}

// pass
const foo: BoolProp<_> = {
  propType: Function,
  prop: () => true,
}

B) If propType is going to be a known set of types you could enumerate a union.

type BoolProp =
  { propType: FunctionConstructor; prop: () => boolean } |
  { propType: BooleanConstructor; prop: boolean };

// pass
const foo: BoolProp = {
  propType: Function,
  prop: () => true,
}

C) Use generic function inference as an escape hatch.

interface BoolProp<T> {
  propType: T;
  prop: T extends FunctionConstructor ? (() => boolean) : boolean;
}

function makeBoolProp<T>(x: BoolProp<T>): BoolProp<T> {
  return x;
}

// pass
const foo = makeBoolProp({
  propType: Function,
  prop: () => true,
});

// fail
const bar = makeBoolProp({
  propType: Function,
  prop: true,
})

Or just suffer the pain of annotating explicitly (I don't think this is that bad).

@RyanCavanaugh RyanCavanaugh added the Question An issue which isn't directly actionable in code label Feb 5, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Question An issue which isn't directly actionable in code
Projects
None yet
Development

No branches or pull requests

5 participants