artsy.github.io
artsy.github.io copied to clipboard
Comments: Conditional types in TypeScript
http://artsy.github.io/blog/2018/11/21/conditional-types-in-typescript/
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/
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!
Thanks for the kind words, peeps :blush: I'm super glad you've found it helpful! π
@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]
Ah yeah! That's an open issue https://github.com/Microsoft/TypeScript/issues/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.
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
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 βοΈ
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.
This is such a fantastic post. Thank you so much for writing it!
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?
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.
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
@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;
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.
Hi @marzelin :wave: 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 :+1: 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.
Great explanations, examples and expressivity :) πππ
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;
}
}
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)
This is my new favorite article I've ever read, anywhere.
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;
@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 π―
@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.
Simply amazing post!
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"
@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?
@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.
I'm not sure if this is intended behaviour, it's like SchrΓΆdingerβs cat in Typescript......
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});
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?
my solution for the exercise, which used spread operator to gather function arguments: link here