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

Suggestion: Void-protected types #6981

Closed
dallonf opened this issue Feb 9, 2016 · 6 comments
Closed

Suggestion: Void-protected types #6981

dallonf opened this issue Feb 9, 2016 · 6 comments
Labels
Duplicate An existing issue was already created

Comments

@dallonf
Copy link

dallonf commented Feb 9, 2016

Deep in the discussion of non-nullable types, there was a new, more practical, suggestion (credit particularly to @tejacques) of voidable types that are protected against unsafe access, while maintaining backwards compatibility. I think the suggestion is a bit too buried in that thread to be useful or noticed, so I'm creating a new issue for this proposal.

First, some definitions to make sure I'm on the same page as everyone else... for the purposes of this discussion, "void" refers to either a value of null or undefined, which will raise a TypeError if you attempt to use it in certain ways, particularly accessing a member (e.g. foo.bar) or calling it as a function (e.g. foo()). Currently in TypeScript, all types are "voidable", by this definition.

The proposal is for a new modifier (I'll use a ? prefix, similar to Flow's implementation, but it could be anything) that will make a type "void-protected". A void-protected type must be checked for existence before it can be used. For example:

function printShout(x: ?string): void {
  console.log(x.toUpperCase()); // Error: x might be void
  if (x) {
    console.log(x.toUpperCase()); // legal
  }
}

This does not affect assignments, for the sake of backwards compatibility:

function foo(x: ?string): void {
  var y: string = x;
  console.log(y.toUpperCase()); // Legal, but probably not a good idea
}

This is similar in philosophy to Java's Optional<> type. While every type in Java is nullable, it has become standard to instead use Optional<> to express this at an API level. While it doesn't completely protect against NullPointerExceptions (aka the "billion dollar mistake"), it's a step in the right direction, and some IDEs will even log warnings if you assign null to a normal, non-Optional type.

In the case of TypeScript, a --noImplicitVoidable flag could be added in the distant future, which would make all types non-voidable (that is, you could not assign null or undefined to them) by default. While this is a major breaking change, it can be mitigated by having "void-protected" types in the language for some time prior. That way, most code will already be using ? prefixes wherever void types are expected.

Finally, under this proposal, there are basically four levels of voidability, which is more relevant if this hypothetical --noImplicitVoidable flag comes to be. A lot of this has been adapted from @tejacques's response in the non-nullable thread:

interface Foo {
  w: string;
  x: ?string;
  y?: string;
  z?: ?string;
}

// alternately
function foo(w: string, x: ?string, y?: string, z?: ?string): void

w: string behaves exactly like it does currently: it must be explicitly set, but it can be void. Under --noImplicitVoidable, it would be a strong guarantee of existence.

x: ?string is new: it must be explicitly set, but can be void. As is, it behaves exactly like w, but provides more safety for a consumer.

y?: string continues to behave as-is. Under --noImplicitVoidable, though, it would probably be treated as "undefinable" - that is, it may have a value of undefined (implicitly, if it's not set) and accessing it must be guarded, but it would never have a value of null.

z?: ?string is equivalent to y, but again provides more safety for consumers and would be preferred over y in most new code.

@tejacques
Copy link

Hey @dallonf, thanks for taking the mantle on this and bringing it back. I'm not sure what the TypeScript teams wants to do with this, but since the last discussion about it took place, there has been a lot learned and changed in the language itself.

I think this is the right way forward, but there is a lot of trickiness surrounding --noImplicitVoidable that needs to be ironed out. I still think it makes sense to tackle the problem as multiple separate ones.

I'm going to repeat a lot of what you just said, but splitting it up into what I see as the logical separations just to get the full picture and why this particular issue is important and related to it.

It's important to note that only the first issue below is encompassed by this proposal. I'm only rehashing the rest of the steps here so that they can be further broken out into new proposals in the future, or discussed here as appropriate.

First Problem - built-in Option type or Void-protected types (no breaking changes -- this proposal!)

This is the ?T type, which internally to the TypeScript compiler would be represented as the type T | void.

This can actually be done right now with a type alias and custom user type-guard, and it works well, but IMO it's not enough and would be much easier and more idiomatic JavaScript to work as a built-in.

Secondly, the optional argument type, x?: string should really be internalized as the currently un-specifiable x | undefined. This simply ensures that a typeguard needs to be used to access it, but it is a different contract than the option type. Specifically, it is either well defined to be of T or it is undefined, it cannot be null. I actually think continuing to use this syntax is preferable to x?: ?string when applicable, because you are saying that you optionally take a non-voidable argument. So if the argument is supplied, it cannot be void.

In order to use an option type variable: x: ?string as a string it must have a type guard.

For any non-generic type other than one that can be falsy (?number, ?boolean, and ?string, possibly others), the following acts as the appropriate type-guard:

let x: ?Object;
if (x) {
  // x is Object
}

This is essentially as elegant as it gets. It's very idiomatic javascript, it's concise, and the intention is clear.

For ?number, ?boolean, ?string or ?T:

let x: ?T
if (x != null) {
  // x is T
}

That guard is not nearly as beautiful as if(x), but most of the time it should be enough. Alternatively it could allow if(x) until it is known that T is a number type, and then give an error. That is more flow-like.

Edit: Because there are a plethora of falsy values, and the if (x) guard can't be used generically, it may not be worth allowing it at all.

Other guards: ||

It is very idiomatic JavaScript to say something like:

x = x || "fallback";

This should implicitly unwrap the option, so if x had been a ?string, now x is a string. Had x been some other type, like object, then you would get a union: object | string.

More concretely: ?T || U -> T | U.

This has the same caveat for falsy values, where the number 0, the boolean false, and the string '' fail the check, but are not void and as such it's not the most appropriate. Nonetheless, it's still an idiom used in JavaScript, and should work the same way type-wise.

Future possibility (Not necessary for any of this to work, but nice to think about): Elvis operator ?.

I think this would make a great future addition to JavaScript in general, and if it does make it, it will make dealing with options very smooth and elegant. That said, I'm only including it here to demonstrate how Options can/will be used and integrated.

The ?. operator would only be available on ?T types, and it would behave exactly as it does in C# and other languages:

This operator will become more important as will be denoted later.

let x: ?string;
let y = x
  ?.substring(1)
  ?.toUpperCase(); // y: ?string

Specifically, if a property or method exists, it uses that property or method, and if it does not, returns null. If the resulting type of that property or method was a U, it becomes a ?U. This means the Elvis operator is exactly equivalent to syntactic sugar over a traditional Option.map or Option.bind.

Second Problem -- Ensuring a variable has been initialized (breaking change, depends on Option types)

This is a really really tough problem. How do you ensure that non-null variables have all been initialized before use? There are some more straightforward things that can be done which cover many cases, but as always it's the corner cases which are difficult:

  • Observed assignment
  • Constructor must set/initialize all non-nullable properties
  • Array construction

If a function can return null or undefined, it should have a return type of ?T.

This is unfortunately where a lot of breaking changes will be required. Specifically in regards to API. There are already loads of existing constructors and functions which do not adhere to this, whose APIs would need to change to return an Option variant. However, this is something that could happen slowly over time. TypeScript can explicitly state that non-null types signatures in code it does not have the source for cannot be enforced, and it is up to the user to ensure they have the proper definitions. The typings in lib.d.ts can also be updated over time to reflect this as well -- each of which will be a breaking change, but in a very minor way. I think this is the hardest part to swallow about the entire approach, but this is substantially mitigated by allowing assignment of non-voidable variables to option types (when --noImplicitVoidable is not given).

For Arrays specifically, this can be mitigated by changing the type of Array<T>(n: number) from T[] to ?T[]. (Note: the type for ?Array<T> would be shorthanded to T?[], ?T[] implies Array<?T>). This can be upgraded to a proper T[] by looping over it and upgraded the assigned type at every entry. Array literals [], the numberless array constructor, and the arguments constructor would not need to return ?T[] as they are well specified.

Another issue is something like JSON.parse<T>(jsonStr) which currently returns a T, but there's no actual guarantee at all that's what you get back. Really it should be changed to return a ?T. The issue here is that if you check that the result is not null, it's upgraded to a T, but you have no guarantee whatsoever this is reflective of reality.

As such, the best TypeScript can do is provide a weak guarantee that non-voidable types are actually non-void. In my opinion this is OK, as it will be correct the vast majority of the time, and will improve as library authors improve and correct their APIs with the option type.

This is also where the Elvis operator can help (in the hopeful future). Because the Elvis operator automatically returns the property as an option type, it realistically represents the true interaction with the result:

type ParseType = { some: { thing: string } };
let parsed = JSON.parse<ParseType>("null"); // ?ParseType
les res = parsed?.some?.thing || "fallback"; // string
//     ?ParseType -> ?{ thing: string } -> ?string || string -> string

Third Problem -- --noImplicitVoidable (breaking change, requires Second Problem)

So far, the above can be accomplished without any need for --noImplicitVoidable. However, when it is added, the behavior is as follows:

  • Assignment of void/undefined to any non-option type is an error.
  • Use of a non-void variable before initialization is an error
  • Option types cannot be implicitly assigned to non-void types (cannot assign a ?T to a T), must use the || operator or a type guard if/else.
  • Only checks/verifies this for the source code it compiles

This is a fairly obvious breaking change as code which previously worked will no longer work, but because it is optional, and very useful, it feels right.

This again gives the general weak guarantee that regular types are not void, with the same caveat that external definitions may not adhere to this contract. However, the more the definitions mature the better adherence to this contract the language will have. This flag changes this from simply a convention into something that is enforced for your own code.

This is a simplification from the suggestion in the previous thread, as it doesn't specify doing any kind of enhanced analysis beyond trying to check/ensure initialization (second point).

***NOTE: The Elvis operator is not required for this proposal, it is only used here to demonstrate a possible future. Any discussion about it should happen either in another issue or as a TC39 / EcmaScript proposal.

@yortus
Copy link
Contributor

yortus commented Feb 15, 2016

@tejacques nit:

For any non-generic type other than ?number, the following acts as the appropriate type-guard:

let x: ?string;
if (x) {
  // x is string
}

Unfortunately ?string and ?boolean couldn't be guarded this way, since empty strings and false are also falsy.

@dallonf
Copy link
Author

dallonf commented Feb 15, 2016

@yortus: well, technically it could, right? If x: ?string is truthy, then you are absolutely sure it is a string. The catch is that if (x) would not detect empty strings, which makes it a bit of an antipattern. (unless your logic mirrors the isNullOrEmpty concept common in C# and Java)

@yortus
Copy link
Contributor

yortus commented Feb 15, 2016

As I understood it, if(x) was proposed as a guard only against null and undefined. For that purpose it must certainly not fail on 'non-void' values that are valid in the domain of their respective types, including zero, the empty string, and false. But it's a nit, not a showstopper - as @tejacques points out there is the more reliable if (x != null) guard, which would need to be used for ?number, ?string and ?boolean among others.

@tejacques
Copy link

@yortus: You're correct about the guard. If it was going to be special cased, it should probably only be on explicit object and array types. I'll update my post to reflect this, but special casing at all might be the wrong thing to do (wouldn't work in generics, and makes the developer need to be aware of context). I only put it in because it's pretty idiomatic JavaScript, and it might clash if TypeScript can't handle that case.

@mhegazy
Copy link
Contributor

mhegazy commented Feb 19, 2016

closing this in favor of #7140

@mhegazy mhegazy closed this as completed Feb 19, 2016
@mhegazy mhegazy added the Duplicate An existing issue was already created label Feb 19, 2016
@microsoft microsoft locked and limited conversation to collaborators Jun 19, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Duplicate An existing issue was already created
Projects
None yet
Development

No branches or pull requests

4 participants