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

Comments: Conditional types in TypeScript #500

Open
ds300 opened this issue Nov 17, 2018 · 35 comments
Open

Comments: Conditional types in TypeScript #500

ds300 opened this issue Nov 17, 2018 · 35 comments

Comments

@ds300
Copy link
Contributor

ds300 commented Nov 17, 2018

http://artsy.github.io/blog/2018/11/21/conditional-types-in-typescript/

@johnnyreilly
Copy link

johnnyreilly commented Nov 21, 2018

Hands down the best thing I've read on conditional types. Well done! The TypeScript should make this part of their official docs! ♥️🌻

cc @DanielRosenwasser
http://artsy.github.io/blog/2018/11/21/conditional-types-in-typescript/

@theutz
Copy link

theutz commented Nov 28, 2018

I have been struggling with understanding this topic almost every day for months. This is the first thing I've read in plain speech—where I could really understand it. Your writing is excellent, entertaining, and informative.

This should absolutely be a part of the TypeScript documentation. Thank you!

@ds300
Copy link
Contributor Author

ds300 commented Nov 28, 2018

Thanks for the kind words, peeps 😊 I'm super glad you've found it helpful! 💃

@j1r1k
Copy link

j1r1k commented Nov 28, 2018

@ds300 How would you implement a body of process function?

export function process<T extends string | null>(text: T): T extends string ? string : null {
  return text === null ? null : text
}

fails with

[ts]
Type 'T | null' is not assignable to type 'T extends string ? string : null'.
  Type 'null' is not assignable to type 'T extends string ? string : null'. [2322]

@ds300
Copy link
Contributor Author

ds300 commented Nov 28, 2018

Ah yeah! That's an open issue microsoft/TypeScript#24929 — I left this out of my talk but should probably have included it in the blog post as another caveat.

Unfortunately the only workaround right now is to cast your return values as any. So usages of the function will still get the benefit of the conditional type, but you can't get TypeScript to check that your function implementation is entirely type safe.

@bdurrani
Copy link

bdurrani commented Feb 4, 2019

With your first code snippet, I get the following error

error TS2322: Type 'string' is not assignable to type 'T extends string ? string : null'.

This is the link to the playground.

I'm probably missing something simple. Any ideas? Did something change recently? I'm using TS 3.2.2
Thanks

@ds300
Copy link
Contributor Author

ds300 commented Feb 4, 2019

Hey @bdurrani! 👋

You're right. That's an open issue: TS can't type-check the return value of functions with conditional return types defined in terms of type parameters. See the link in the comment above yours for more info ☝️

@bdurrani
Copy link

bdurrani commented Feb 4, 2019

Hey @bdurrani! 👋

You're right. That's an open issue: TS can't type-check the return value of functions with conditional return types defined in terms of type parameters. See the link in the comment above yours for more info ☝️

I should have look at that. Thanks for responding. great article.

@Daniel15
Copy link

Daniel15 commented Mar 4, 2019

This is such a fantastic post. Thank you so much for writing it!

@schmod
Copy link

schmod commented Apr 9, 2019

Thanks for the fantastic post!

I'm wondering if this is similar to @bdurrani's issue, but I'm having trouble finding a way to write a typesafe implementation of a dispatch function that takes advantage of type-narrowing of the args parameter:

function dispatch<T extends ActionType>(
  type: T,
  args: ExtractActionParameters<Action, T>
): void {
  if (type === 'LOG_IN') {
    console.log(args.emailAddress) // error
  }
}

Inside of the if statement, TS hasn't actually narrowed the definition of args, and has instead left it as the union of all possible values of args.


The best thing I've come up with so far is this (fairly ugly) type-guard:

function isType<T extends ActionType>(
  desired: T,
  actual: ActionType,
  args: ExcludeTypeField<Action>
): args is ExtractActionParameters<Action, T> {
  return desired === actual;
}

function dispatch<T extends ActionType>(
  type: T,
  args: ExtractActionParameters<Action, T>
): void {
  if (isType("LOG_IN", type, args)) {
    console.log(args.emailAddress); // 👍
  }
}

Have others run into this, and come up with a way to make the TypeScript compiler be a little more intuitive without resorting to type-guards or unsafe casts?

@ds300
Copy link
Contributor Author

ds300 commented Apr 10, 2019

I suppose the problem is that TypeScript can't yet narrow the types of two independent variables, even if their types are codependent.

One thing I might be tempted to try would be combining the values back into a single Action object.

function dispatch<T extends ActionType>(
    type: T,
    args: ExtractActionParameters<Action, T>
): void {
    const action = {type, ...args} as Action
    switch (action.type) {
        case "LOG_IN":
            action.emailAddress // 👍
            break;
    }
}

Obviously not ideal if you need to run dispatch in a tight loop.

@hienqnguyen
Copy link

Thank you for the post. It is extremely helpful. Could you please advise how I can address the issue below?

const myFunc = [
  { func: (s: string): number => parseInt(s) },
  { func: (n: number): boolean => n === 17 }
];

type FuncType = (typeof myFunc extends Array<(infer A)> ? A : never)["func"];
type ExtractInput<T> = T extends (i: infer I) => any ? I: never;
type ArgsType = ExtractInput<FuncType>;

const executeFunc = (f: FuncType, input: ArgsType) => f(input);

compiier doesn't like f(input), it says Argument of type 'string | number' is not assignable to parameter of type 'string & number.

See it Playground

@ds300
Copy link
Contributor Author

ds300 commented May 12, 2019

@hienqnguyen Yeah I see what's going on there. That's the correct behaviour. Let me break it down:

FuncType ends up being a union of the two function signatures in myFunc. i.e.

type FuncType = ((s: string) => number) | ((n: number) => boolean)

So when you ExtractInput on it the the union gets distributed over

type ArgsType = ExtractInput<(s: string) => number> | ExtractInput<(n: number) => boolean>

and so

type ArgsType = string | number

But if you try to call FuncType you can't pass a string | number because there's a 50/50 chance that there's a type error. If you pass a string then the function that expects a number will fail. If you pass a number than the function that expects a string will fail. We don't know which function will be called so the only safe thing to pass is something which is both a string and a number, i.e. string & number, so that's what TypeScript asks for.

I think a way to fix this would be to avoid making ExtractInput a distributive conditional type.

A hacky way to do that which just popped into my head would be to wrap both sides of the extends clause with some arbitrary type wrapper. e.g. Array

type ExtractInput<T> = Array<T> extends Array<(i: infer I) => any> ? I: never;

You could do it with a tuple for fewer characters, but even more confusing to read IMO (definitely add a comment if you decide to use either of these 😅)

type ExtractInput<T> = [T] extends [(i: infer I) => any] ? I: never;

Check it out in the playground

@marzelin
Copy link

TypeScript won't let us pass something that is of type string | null because it's not smart enough to collapse the overloaded signatures when that's possible. So we can either add yet another overload signature for the string | null case, or we can be like (╯°□°)╯︵ ┻━┻ and switch to using conditional types.

function process<T extends string | null>(
text: T
): T extends string ? string : null {
...
}

No, we cannot use conditional types here since the signature is invalid. It's not a bug, it's a design limitation so it will probably never work.

class A {}
class B {}
const b: B = new A() // ✔ all good
const a: A = new B() // ✔ all good
new A() instanceof B // => false

TypeScript is happy treating two completely unrelated classes as equivalent because they have the same structure and the same capabilities. Meanwhile, when checking the types at runtime, we discover that they are actually not equivalent.

types and classes are different things: a type refers to interface and a class refers to implementation. instanceof doesn't check types.

interface Shape {
color: string
}
class Circle {
color: string
radius: number
}
// ✔ All good! Circles have a color
const shape: Shape = new Circle()
// ✘ Type error! Not all shapes have a radius!
const circle: Circle = shape

Speaking structurally we can say that A extends B is a lot like 'A is a superset of B', or, to be more verbose, 'A has all of B's properties, and maybe some more'.

A extends B is a lot like 'A is a subset of B'
Circle is a subset of Shape.
A type or set is super if it contains more elements (or at least it has all elements from the other set). It's all about quantity not quality. Superman isn't a superset of Man even though he's much more powerful than a normal man. Supertype is always more basic and subtype more sophisticated or specific.

@ds300
Copy link
Contributor Author

ds300 commented May 21, 2019

Hi @marzelin 👋 Thanks for the feedback! I will address your concerns individually

No, we cannot use conditional types here since the signature is invalid. It's not a bug, it's a design limitation so it will probably never work.

The type signature is legal, you can try it out :) The problem you highlight might be the one already discussed earlier in this thread, i.e. that TypeScript can't safely check the return values of a function with a conditional return type.

instanceof doesn't check types.

If by 'type' you mean 'TypeScript type' then yeah 👍 I specifically mentioned 'runtime' type checking though, so hopefully people picked up on the fact that I wasn't only talking about TypeScript types in that particular sentence. Maybe it wasn't clear enough. Thanks for pointing it out!

Circle is a subset of Shape.

I think I can see how this is confusing.

I was implicitly using the mathematical notion of a 'set' here.

A TS interface is a set of properties. In that regard, Circle is not a subset of Shape because it includes properties that do not exist in Shape. Rather Circle is a superset of Shape because Shape includes some of the properties of Circle, but no properties that don't exist in Circle.

The confusion is likely coming from the fact that in type systems we use the terms 'subtype' and 'supertype' to refer to exactly the opposite kinds of inheritance relationships to 'subset' and 'superset' in maths 😅

Maybe I could have done more to highlight this distinction! Thanks again, this was useful feedback.

@GabrielCTroia
Copy link

Great explanations, examples and expressivity :) 🙏🙏🙏

@joarfish
Copy link

joarfish commented Aug 6, 2019

I suppose the problem is that TypeScript can't yet narrow the types of two independent variables, even if their types are codependent.

One thing I might be tempted to try would be combining the values back into a single Action object.

function dispatch<T extends ActionType>(
    type: T,
    args: ExtractActionParameters<Action, T>
): void {
    const action = {type, ...args} as Action
    switch (action.type) {
        case "LOG_IN":
            action.emailAddress // 👍
            break;
    }
}

Obviously not ideal if you need to run dispatch in a tight loop.

Has anyone tried this approach yet? My compiler complains that the respective property does not exist on type Action (Which is to be expected as Action is an union type).

Edit: This works:

function dispatch<T extends ActionType>(
    type: T,
    args: ExtractActionParameters<Action, T>
): void {
    switch (action.type) {
        case "LOG_IN":
            const action = {type, ...args} as {type: "LOG_IN", emailAddress: string};
            action.emailAddress
            break;
    }
}

@jcao02
Copy link

jcao02 commented Sep 15, 2019

Thank you for this article! I'm working in a Vue.js + Vuex app and types in Vuex are not the best, and the article helped me understand many concepts and ended up creating super strong types for dispatch and commit methods.

I would recommend changing the name though. I found it because I was googling to solve a different problem (which I solved too haha)

@jamesona
Copy link

This is my new favorite article I've ever read, anywhere.

@vicke4
Copy link

vicke4 commented Feb 26, 2020

As an intrepid reader, I've solved the exercise given at the end of the post 😅.

Declaring ExtractActionParameters as follows solves the second parameter issue for SimpleActionType.

type ExtractActionParameters<A, T> = A extends { type: T }
  ? {} extends Omit<A, 'type'>
    ? never
    : Omit<A, 'type'>
  : never;

TypeScript playground link

@ds300
Copy link
Contributor Author

ds300 commented Feb 26, 2020

@vicke4 Awesome! A few people have sent me solutions to that challenge and I'm amazed that they've all been different! Yours is very neat 💯

@vicke4
Copy link

vicke4 commented Feb 26, 2020

@ds300 First of all, thank you so much for the blog post. It is helping a lot of users to understand the concept. I'd really love to see other solutions. If you don't mind, please share it in a gist.

@nandorojo
Copy link

nandorojo commented May 21, 2020

Simply amazing post!

@tristanreid
Copy link

What a phenomenal post! Thank you. I'm new to TypeScript, and this really opened my eyes a lot.

I looked further into the infer keyword, and noticed the co-variant=>union / contra-variant => intersection behavior that I believe you're illustrating toward the end of your blog. It took a little puzzling on my part (and reminders of those definitions), but I've worked out an example to illustrate:

type Apple = "red" | "fruit"
type Pear = "green" | "fruit"

type Union<T> = T extends [infer A, infer A] ? A : never

type ApplesOrPears = Union<[Apple, Pear]>
//  "green" | "fruit" | "red"

type Intersect<T> = T extends [
  (param: infer A) => void,
  (param: infer A) => void
 ] ? A : never

type AppleFunc = (param: Apple) => void
type PearFunc = (param: Pear) => void

type ApplesAndPears = Intersect<[AppleFunc, PearFunc]>
// "fruit"

@IonelLupu
Copy link

@vicke4 @ds300

I found a small issue with this solution. Calling dispatch like so:

dispatch('LOG_IN')

Will give an unexpected error like: Argument of type '"LOG_IN"' is not assignable to parameter of type '"INIT" | "SYNC"'

The real error should sound like: Expected 2 arguments but found one. But I think Typescript isn't yet capable to change the argument's modifier from required to optional conditionally :(

Is there a solution that fixes this?

@lazharichir
Copy link

lazharichir commented Aug 3, 2020

@IonelLupu yes, you can make the second argument for dispatch optional like so (note the ? after args):

function dispatch<T extends ActionType>(
  type: T,
  args?: ExtractActionParameters<Action, T>
): void {
  console.log(type, args)
}

But that could be unsafe so look at the end of the post – https://artsy.github.io/blog/2018/11/21/conditional-types-in-typescript/

Also came here to thank the author for this incredible piece. It helped me with type inference from a stringified argument.

@bingtimren
Copy link

bingtimren commented Aug 14, 2020

I'm not sure if this is intended behaviour, it's like Schrödinger’s cat in Typescript......

Screenshot from 2020-08-14 13-01-59

@DavidGoldman
Copy link

DavidGoldman commented Oct 2, 2020

Thanks for this article, this was really informative! Have you considered using a different style approach though? I guess it's roughly the same functionally just doesn't show off the conditional types.

interface EventMap {
  start: {time: number};
  stop: {time: number};
}

And then you instead reference the EventMap's key -> value mapping, using the key as the identifier:

class Dispatcher<EventMapT> {
  dispatch<K extends Extract<keyof EventMapT, string>>(key: K, payload: EventMapT[K]): void {
  }
}
const dispatcher = new Dispatcher<EventMap>();
dispatcher.dispatch('start', {time: 0});
dispatcher.dispatch('stop', {time: 10});

@nandorojo
Copy link

I still find myself coming back to this article months later. I would pay to subscribe to a newsletter of TypeScript articles like this if you’ve ever considered it.

Has anyone come across a similar piece for TS 4.1’s new features?

@Yoomin233
Copy link

my solution for the exercise, which used spread operator to gather function arguments:
link here

@dzek69
Copy link

dzek69 commented Feb 18, 2021

Hello,
I've read some links about conditional types, I've tried to implement them to my code but I failed. Then I found this article and even following the simplest example makes TypeScript complain, Look at this example:

const processStuff = <T extends string | null>(
    text: T,
): T extends string ? string : null => text && text.toLowerCase(); // TS2322: Type 'string' is not assignable to type 'T extends string ? string : null'.

const t = processStuff("elo");
const n = processStuff(null);

t is correctly identified as string
n is correctly identified as null

but TS complains.

What is wrong? I've tried that on TS 4.1.2 and 4.1.5

@ds300
Copy link
Contributor Author

ds300 commented Feb 18, 2021

Hi @dzek69 this was discussed above in #500 (comment)

Apologies for the confusion

@dzek69
Copy link

dzek69 commented Feb 18, 2021

@ds300 Wow. It's been a while, I'm usually avoiding reading stuff from 2+ years ago, because it's usually out of date. Wow.

Thanks for your comment.

@IRediTOTO
Copy link

Thank you for a longgggggggggggg and great post

@bkyerv
Copy link

bkyerv commented Mar 16, 2022

Thank you for taking time and sharing your knowledge publicly for free. I am a beginning software developer attempting to learn typescript and am shocked how difficult certain parts are. I don't even understand 10% of the article but still can recognize that it is something of very good value (well I read through all comments=)). I really hope in 3-4 months I will get to a level where I will be able to understand at least 50% of what is written in the article. Thank you again; your time spent on this material is appreciated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests