TypeScript: Control flow analysis for destructured discriminated unions
github.com
github.com
If some of the noise were eventually removed and tagged unions became true first class citizens in TS' syntax, I think some of these colleagues without as much typing experience might be more open to the idea—or at the very least understand it in the first place.
Maybe the official handbook could help convince them this isn't the case: https://www.typescriptlang.org/docs/handbook/2/narrowing.htm...
The very, very related thing I found myself wishing I had just today is the ability to destructure discriminated union fields which only exist on one component type of the union.
As an example, with the following type system:
type Output = OutputExists | OutputDoesntExist;
interface OutputExists { exists: true, url: string };
interface OutputDoesntExist { exists: false };
The ability to destructure like: const f = ({ exists, url }: Output) => {}
Such that, at the time of destructuring: exists: true | false
url: string | undefined
I believe, with this PR, if we re-structured the types above to: interface OutputDoesntExist { exists: false, url: undefined };
That destructuring would be possible, with all the control flow analysis necessary to make it useful. Today, even that wouldn't be possible, so definitely excited for this change.But, I'm curious if anyone knows if this change can extend to situations where a field isn't even defined in one of the unions, and the compiler can just insert an undefined type for those fields. Or, maybe this would have some unresolveable side-effects I'm not considering.
In short:
interface OutputDoesntExist { exists: false };
// error, as ever :(
0 - https://www.typescriptlang.org/play?ts=4.6.0-dev.20211103#co... interface OutputDoesntExist {
exists: false;
url?: never;
}
Now it’s:- Optional in the union type
- Always present and has the correct type in the exists: true case
- Still optional in the exists: false case, but if you try to access it you always get a type error
I'm trying to think of a reason why the compiler couldn't allow that destructuring, implicitly type the values missing in some components of the union as 'T | ... | never', then lean on the control flow analysis it looks like we're getting in 4.6 to specify the type as the discriminated union is de-discriminated (lol). JS itself does allow the destructuring of elements missing from an object, they come out as undefined, so I don't believe there'd be any underlying issues.
Hopefully we see it in 4.7!
The only underlying issue I could foresee is async. Eg narrow, await, infer. But I’d assume that affects all similar cases.
IMO this is as good a time as any to introduce an immutable-by-default mode. If you know it won’t change you can trust the previously narrowed type at any async boundary.
In my own personal projects I make nearly everything immutable at the type level and, while it makes my code safer it’s an enormous pain in the butt to type readonly on every property. As a matter of habit it has one other benefit though: you never have to worry about accepting `as const` values in function calls.
This isn’t right. It’s not assignable to anything other than never, that’s why never is so useful for this use case.
What I see in OutputExists/OutputNotExists is actually a Maybe/Option type.
doSomething<Maybe<Output>>(someObj)
.map(({ url }) => url)
.getOr(null)
// value could either be url or nullA better example: think of Kubernetes API objects. In short, expressed as typescript interfaces:
type KubeObject = Pod | Service;
interface Pod { kind: "Pod", metadata: Metadata, spec: { containers: Container[] } };
interface Service { kind: "Service", metadata: Metadata, spec: { selector: ServiceSelector } };
const f = ({ kind, spec: { containers, selector } }: KubeObject) => {}
So, the ability to destructure both 'spec.containers' and 'spec.selector' given just a KubeObject, and have the compiler use control flow analysis on 'kind' to mark one as 'never' or 'undefined' when its destructured on a type where it doesn't make sense.So, you end up with two different runtime memory representations that when you query an object's property can both result in an "undefined". Cause of this is Javascript's laissez-faire approach to type safety. The implicit conversions (e.g. string+number+anyType works) are one, and not being strict about objects and their properties is another. In a strict language accessing a non-existent property would be an error and not even make it through the compiler.
Somewhat unrelated to the question I responded to:
Typescript is not a different language at all, it really only adds type annotations and the actual language is 100% ECMAscript apart from namespaces (no longer necessary, they are from pre-ES2015 times when JS was lacking features) or enums and maybe some minor class helper stuff nothing at all needs to be transpiled to get from Typescript to ECMAScript. You just need to remove the type annotations. That is why TS ends up with still having issues because it cannot deny the easy-going attitude of the code.
Typescript is not the best architecture: It is a monolith software that supports completely different things in one giant piece of software. We used to have compiler, linker, profiler, code analysis tools, all separate tools.
Typescript in one piece bundles type checking and transpiling in one, and on top of that a quite elaborate and extensive editor support (language service for editors to use). For no good reason, you can not use the transpiling feature at all and only use TS with an IDE to give type advice, and then use Babel to remove the Typescript annotations. Transpiling is really from Javascript to Javascript - working around newer ECMAScript features that a targeted older runtime does not have.
I think Microsoft did a great disservice to their own product by crating this monolith, which unfortunately also created the impression that Typescript is a different language.
If they had it split, and/or made that split clear, that type annotations are just added on top of whatever the latest ECMAScript is, and that transpiling is separate and does not work on a whole new language, and is only necessary when targeting platforms that don't support newer ECMAscript features used in the code, there might be less confusion about what TS is. Flow, the other type system, always made it clear it's a type-annotation add-on. TS is actually not different, they just chose to also bundle the transpiler.
This monolith also makes TS a lot less flexible: If you only had type annotations and checking and transpiling separate you could have more flexibility. You can extend Babel with your own modules easily, e.g. to add a module that removes debug code in a production build. With TS you are limited to using exactly the features they implemented. This is less about the type stuff, more about the environment. For example, I have code that keeps different platform versions at once, and in the build only one is used. I have no way of telling TypeScript that. If the tool was split and as flexible as Babel it would be easy to write a tiny module or plugin.
Babel has had extensions (including semi-official ones supported by the Typescript team at this point) supporting the removal of Typescript type annotations and there are many projects in the wild today that use Babel entirely for transpilation and Typescript only for language services (IDE).
Relatedly, a bunch of linting that Typescript did but was more generally useful has moved to upstream ESLint, and upstream ESLint also more directly supports Typescript type annotation removal.
x.b and x.c are both undefined, but `'b' in x` is true, while `'c' in x` is false.
You can check what keys have been provided, and... well, you could use that to determine things about your data.
For instance, it's sooo easy to write a React component taking in another component as a prop and some props for that other component, to rendering it. But somehow lose the type safety of the props being passed in. Like, you write some generics and the compiler stops complaining inside your component. But if you go back out and then try to pass some weird props instead, it doesn't care. I feel it's too easy to write code that looks tyoe safe but subtly isn't.
1. Typescript has to work with Javascript, which inherently makes things difficult and you have to choose between correctness or ergonomics. TS went for the latter, which probably was a good decision, otherwise no one would use it now.
2. While I have a lot of respect for the creators of TS, the language is not very consistent and uses a lot of "special cases" or "special features" to solve specific problems. But it also means that it becomes hard to ensure that all these features work together well and in the way that users expect. Other languages lack features that TS offer, but they feel more "consistent" or reliable.
That's very interesting. Could you please provide an example that showcases that inconsistency?
This comes from trying to fix 1) compiler errors in conditions where the code is safe but the compiler can't understand that and 2) destructuring products/co-products.
Even if both features work on their own and help in certain cases, combining them leads to undesired results.
should there be a link to a PR in there somewhere?
>"While I have a lot of respect for the creators of TS,"
Creators of TypeScript are Microsoft.
To any readers unfamiliar with TypeScript and Microsoft's ethics,
be sure to research their past corporate ethics to understand whether or not they are trustworthy as a company (or at least those who lead their overarching market strategies.
Here is one example, and to me, it describes where TypeScript is going (imho):
"The strategy's three phases are:
---> Embrace: Development of software substantially compatible with a competing product, or implementing a public standard.
---> Extend: Addition and promotion of features not supported by the competing product or part of the standard, creating interoperability problems for customers who try to use the "simple" standard.
---> Extinguish: When extensions become a de facto standard because of their dominant market share, they marginalize competitors that do not or cannot support the new extensions."
https://en.wikipedia.org/wiki/Embrace,_extend,_and_extinguis...
For this reason, out of sheer convictions in anti-unethical corporate behavior, I steer clear of TypeScript, and I encourage others to do so as well-- especially those who care about corporate ethics.
I agree that Microsoft's influence should be observed and considered carefully.
It seems matsemann fundamentally missed the idea of TypeScript, as it's very clear it only offers compile-time checks and nothing for the runtime.
On the other hand, TypeScript success in adoption is based on following another proven attempts, C++ and Objective-C.
With such approaches, the warts of the copy-paste compatibility with the underlying platform are both a sin and a curse.
Oh god I hope not. JavaScript is hopefully here to stay, and will remain the runtime of the web together with WASM.
TypeScript is a neat idea for enterprise companies where engineering standards tend to not be that great, but that doesn't mean we should lower the possibilities of the current runtime to the lowest common denominator.
> it's sooo easy to write a React component taking in another component as a prop and some props for that other component ... but somehow lose the type safety of the props being passed in.
Previous comments interpreted this, I think, as "why don't I have type safety at runtime." Obviously, it's really little more than a very powerful static analyser on top of a superset of JavaScript. I'm not certain you were asking that; without a little more specifics to what you were trying to do or a code example, I'm not positive I'm understanding the issue you're describing correctly, but I think you're saying that in certain cases, with a React component where another React component is being passed as a parameter, along with other props, you can still reference the root component somewhere else with the wrong parameters and TypeScript doesn't complain.Personally speaking, I have never written React without TypeScript in the last 3 years and have spent about half of my work time in that space. If you haven't taken a recent look and tried the scenario you describe in the latest version of the language, I'd suggest starting there. I remember, early on -- way prior to Hooks and the like -- we were looking at various tooling options around state management (react-redux/mobx/others) to use for a large project and ended up not choosing react-redux due to it being impossible/almost-impossible to get type safety in some cases[0].
Ninety-nine percent of the time when `tsc` fails to produce an error/indicate something useful to you in your IDE of choice like this, it's because implicit typing couldn't figure something important out and the project's configuration was set lax enough to quietly ignore it. One easy fix for me was to go full `strict`; disallow `any`. When typing can't be determined implicitly, you get a shiny error. This leads to the second thing that becomes necessary: providing types explicitly for functions/others -- in certain cases, sometimes because it doesn't work without it, sometimes just to have it clearly defined/obvious. Type aliases help a lot for drying things up and keeping the "type noise" to a minimum.
It's also possible that whatever tooling you're using to package/build things is interfering in some way (or publishing the result of the compile regardless of errors/warnings).
The strictest settings will bring a lot of new errors, and you'll probably be adding a lot of type info and adjusting code accordingly if you enable these in a project that was more lax, however, I've found it's usually worth it. The issues tend to also bring visibility to a really subtle bug or ... fifteen. Run the latest versions of TS -- improvements around this come constantly. And these days, support for TS on the library side is very solid[1].
Of course, it's still based on a dynamic language and has dynamic elements to it. It's always possible to violate the type system (heck, you can toss a comment in and violate it on a given line if you wish), but the problem you're describing I haven't remembered being a large source of grief, and most of the issues I used to run into from time to time aren't issues in recent versions.
If I misread your scenario, let me know; I've been pretty amazed at the kinds of things I can express with their type system and how well its compiler enforces those things[2] so I'm curious about where it really falls down.
[0] The specifics elude me, but I recall it had to do with the line that wrapped the component; this was three years ago and I had never re-examined the issue after that.
[1] Though I'd not encountered a component/library that I couldn't find a solid alternative to or workaround in some way to get it working in TS.
[2] It'd be nice if those extended in a manner that would handle things one encounters with Json data not quite matching up type-wise but a lot of that is because "it doesn't know how the object is going to be used/loaded at runtime" and you can't really expect it to do so.
A bit hard to provide an mvc of the issue I'm thinking of. But I often see it happen with HOC kinda stuff. Like I might have a loader component, that I pass three things to: the query to run, a component to render, and some additional props to the component to render. The render component will get the props, plus the result of the query as well.
When adding typing to this, ts will naturally complain. So fix that by adding generics etc. And then everything looks fine according to the ts compiler, so you're happy.
But if you try to change the typing of the inner component, or some type of the props being sent to the outer component, you might find that ts is still happy. Somewhere along the line, it decided to throw its hands up and allow everything. So the typing is just complex noise, not catching subtle bugs.
Of course, people could easily write a correct example handling this. But the problem is that there are so many complex ways to solve stuff in ts, and some of them are wrong even though they look correct.
Some of it might be fixed using a stricter setup as you mention.
function x(y: {
foo: number,
bar?: number
}) {}
const y = {
foo: 1,
baar: 2, // typo
}
x(y)
The above is caught in Flow when exact types are enabled but not in TS.TS issue https://github.com/microsoft/TypeScript/issues/12936
TS has a concept of "freshness" which catches some of these mistakes when you construct y right in the arguments of x, but it gets easily broken by accident and without a warning.
2. Nominal types, so you can have for example "Id" type that is a number but not to be mixed with other numbers, you can't just call a function that expects an Id with literal 42. Classes are nominal too, which I think is a solid choise.
3. Type variance & related safeguards
function x(y: {
foo: string | number
}) {
y.foo = 1
}
const y: {
foo: string
} = {foo: 'a'}
x(y)
y.foo.toUpperCase()
Caught by Flow but not TS.4. Local call site type inference of function argument types, so you can have some helper functions within a file that you don't need to explicitly annotate, and Flow will happily infer types based on how you call it.
5. Performance has gotten prerty good with recent versions, to the point where I suspect it's faster than TS. I don't have any hard data on this though.
Even though in many many ways Flow is worse than TS, we have still stuck with it, and that's only like 80% because it'd be s huge undertaking to make the switch. 20% is because some of these features are actually nice :)
Structural typing is one of the best features of TS in my opinion, I like it in general but it's a better fit for JS ecosystem.
This is far from perfect, but when you're working with very tricky code and want to be sure, you at least have the option
If you add nominal types, some people might expect them to be boxed, and thus be able to reflect on types at runtime. So I assume that TypeScript maintainers have made a design decision that users should have to “self-box” nominal types.
I think that's it these days. Flow used to have (slightly) more features but now TS has way more already and even more to come. The Flow people moved on to Recript, or whatever name they call it these days.
[0] https://flow.org/try/#0C4TwDgpgBAggxsAlgewHZQLwCgq6gHygG8oBr...
A record is a set of key-value pairs with the value having a given type (also called a struct, object, etc. depending on the context). In TypeScript, this is an example of a record:
const foo = { bar: 1, baz: "qux" };
Destructuring is an ergonomic feature where you can take the fields of a record and assign them to local variables. For example: // Equivalent to
// const bar = foo.bar;
// const baz = foo.baz;
const { bar, baz } = foo;
Besides ergonomics, destructuring can offer features like exhaustiveness-checking, to make sure that you used all of the fields of a record.A union type is a type which denotes that the value is one of two different types, indicated like this:
type Foo = number | string;
Sometimes, you have two different cases with the same type, and you want to distinguish between them. For example: // Not helpful -- we can't tell from the value
// whether it's supposed to be a username or an email.
type UsernameOrEmail = string | string;
To handle this case, you can use a discriminated union instead. These unions have a special value which unambiguously indicates which case is present: type UsernameOrEmail = { type: "username", value: string }
| { type: "email", value: string };
Sometimes, you have a discriminated union with different fields: type ApiRequest = { type: "message", subject: string, contents: string }
| { type: "view-posts", user_id: number };
Currently, TypeScript uses control-flow analysis to ensure that you're using the right kinds of fields depending on the discriminator value: const request: ApiRequest = getRequest();
if (request.type === "message") {
// Okay:
console.log("Subject is", request.subject);
// Rejected by compiler:
console.log("User ID is", request.user_id);
}
However, you can't combine this feature with destructuring: type Action = { kind: "A", payload: number }
| { kind: "B", payload: string };
const { kind, payload } = getAction();
if (kind === "B") {
// Not currently allowed:
payload.toUpperCase();
}
With this pull request, you can do the above. This can be convenient with patterns like reducers (see https://github.com/microsoft/TypeScript/issues/46143 for an example).“What’s a union?”
“Okay… then what’s a discriminated union?”
“Okay… let’s apply destructuring to one…”
“Okay… now what would analysis look like there given what I know?”
“Okay… how do we have the computer know what I know intuitively as a human given this context?”
“Okay! I think I got it!”
:-). If you’ve gotten this far with programming to understand the examples above, my guess is if you just take the time you can do it without the examples if you take it in small enough chunks :-).
Life’s all about what you apply your energy too
(And also, wonderful example :-) )
https://github.com/sindresorhus/type-fest/blob/main/source/r...
I can walk through it with enough pause, but it kinda makes my head implode when I glance at it.
{ [Key in KeysType]-?: ... }
TIL that you can disable ? and readonly in mapped types using -. So in the above code, every Key will be mandatory (-?) even if the original one in KeysType was optional.source: https://www.typescriptlang.org/docs/handbook/2/mapped-types....
I guess in a way, the issue is that it is cloaked with familiarity. Your brain gets so used to understanding sentences on first go it’s not used to not understanding it.
However, if it was a chemistry equation and you weren’t familiar with chemistry it would immediately understand that comprehension would take time and thus the mind implosion due to a lack of understanding doesn’t occur.
type HttpVerb = "GET" | "POST" | "PUT" | "DELETE";
But you can also do more complex stuff: type User = {name: string; password: strings} | OAuthID;
By themselves they aren't hard to understand or use.As an aside, compared to sum types, you don't have to define them before using them: a function can return number | null while in language with sum types you usually define option first and then return option<number>.
In a language like TypeScript, they can be used for function overloading since there is no runtime dispatch based on argument type. But usually they're used as sum types in the same way as e.g. Rust enums.
Typescript supports so-called "singleton types", in which a given type is inhabited by exactly one value. This lets you do things like specify not just `string` but specific values like `"foo" | "bar"` as a type. In these cases, I often use them in combination with Typescript's "mapped types", which let you index into a map with a certain type and get another type out. This can be useful for things like RPC calls, where the type of the output depends on the type of the input.
Ah, this answers my question I guess. Thanks for the info. I'm about to start a new job doing Typescript stuff so I am going to be deep diving into the language to get my head around it, so this helps a lot :-)
The usefulness of this obviously depends on pattern matching control flow ops in Rust that don't exist in TypeScript but it would make my life just so much easier.
Edit, example of exhaustiveness checking:
switch(obj.kind) {
case "one": return ...
case "two": return ...
case "three": return ...
default:
// @ts-expect-error
throw Error("Didn't cover " + obj.kind)
}
This will actually err at compile time if you missed a case, because otherwise obj.kind will have type "never", which will cause a type error on that line, which will be expected due to the directive comment. If obj.kind is not "never", the code will not have an error, and so the directive will cause an error....it is definitely preferable having language-level support for this stuff though.
To illustrate how TS discriminated unions are functionally equivalent, check out this playground link showing how ReScript (an alternate syntax of OCaml which can compile to JavaScript) compiles an OCaml variant to a JavaScript object with a "TAG" key that could easily be typed as a discriminated union in TypeScript:
https://rescript-lang.org/try?code=C4TwDgpgBAThCOBXCBnYBlYBD...
Actually, in practice I find TS union types much more useful than Rust enums, because you can mix and match options in different combinations freely. In Rust, you can't use one of the enum variants as a type on it's own, and I had to tediously create a lot of wrapper types that I didn't in TS.
On the other hand, Rust's enum have to be defined before use: I can't suddently decide to have a function that returns "Toto" | "Tata" without creating a type or returning string.
OCaml has something called "polymorphic variants" that seem to be a middle-ground between the two: https://ocaml.org/manual/polyvariant.html.
AFAICT this will have the most impact on folks using Typescript with Redux stores and similar dispatchers.I know I would have liked having this feature back when I was doing that work.
Like `if (action.kind === 'x')` / `switch (action.kind)`.
This "only" extends support to destructured discriminators.
Definitely nice to have, but of limited impact.
Additionally, RTK has been designed to work great with TypeScript. Typically all you need to declare is the type of the state for that slice, and the payload for each reducer's action, and everything else is inferred.
See our Redux docs tutorials for details:
https://redux.js.org/tutorials/essentials/part-2-app-structu...
https://redux.js.org/tutorials/fundamentals/part-8-modern-re...
But as always, context is king and no solution fits everything. But hype makes it seem like a tool fits everything, to the detriment of everyone else.
Simple example:
Lets say you can submit one of these payloads
{ type: 'SET_PERSON', payload: Person } and { type: 'SET_AGE', payload: number }
Based on what type field equals to, TLS (Typescript Language Server - used by IDEs) and TSC (TypeScript Compiler) will know type of payload. It’s already possible to achieve that to some extent, but it’s really limited.
@typescript-bot pack this @typescript-bot test this @typescript-bot user test this @typescript-bot run dt @typescript-bot perf test this
God, 19 out of 22 comments are all about CI and should not even be in the PR. How can people effectively work with so much noise in their PRs?