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

unknown: less-permissive alternative to any #10715

Closed
seanmiddleditch opened this issue Sep 5, 2016 · 74 comments
Closed

unknown: less-permissive alternative to any #10715

seanmiddleditch opened this issue Sep 5, 2016 · 74 comments
Labels
Fixed A PR has been merged for this issue In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@seanmiddleditch
Copy link

seanmiddleditch commented Sep 5, 2016

The any type is more permissive than is desired in many circumstances. The problem is that any implicitly conforms to all possible interfaces; since no object actually conforms to every possible interface, any is implicitly type-unsafe. Using any requires type-checking it manually; however, this checking is easy to forget or mess up. Ideally we'd want a type that conforms to {} but which can be refined to any interface via checking.

I'll refer to this proposed type as unknown. The point of unknown is that it does not conform to any interface but refines to any interface. At the simplest, type casting can be used to convert unknown to any interface. All properties/indices on unknown are implicitly treated as unknown unless refined.

The unknown type becomes a good type to use for untrusted data, e.g. data which could match an interface but we aren't yet sure if it does. This is opposed to any which is good for trusted data, e.g. data which could match an interface and we're comfortable assuming that to be true. Where any is the escape hatch out of the type system, unknown is the well-guarded and regulated entrance into the type system.

(edit) Quick clarification: #10715 (comment)

e.g.,

let a = JSON.parse(); // a is unknown
if (Arary.isArray(a.foo)) ; // a is {foo: Array<unknown>} extends unknown
if (a.bar instanceof Bar) // a is {bar: Bar} extends unknown
let b = String(a.s); // b is string
a as MyInterface; // a implements MyInterface

Very roughly, unknown is equivalent to the pseudo-interface:

pseudo_interface unknown extends null|undefined|Object {
   [key: any]: unknown // this[?] is unknown
   [any]: unknown // this.? is unknown
   [key: any](): ()=>unknown // this.?() returns unknown
}

I'm fairly certain that TypeScript's type model will need some rather large updates to handle the primary cases well, e.g. understanding that a type is freely refinable but not implicitly castable, or worse understanding that a type may have non-writeable properties and allowing refinement to being writeable (it probably makes a lot of sense to treat unknown as immutable at first).

A use case is user-input from a file or Web service. There might well be an expected interface, but we don't at first know that the data conforms. We currently have two options:

  1. Use the any type here. This is done with the JSON.parse return value for example. The compiler is totally unable to help detect bugs where we pass the user data along without checking it first.
  2. Use the Object type here. This stops us from just passing the data along unknown, but getting it into the proper shape is somewhat cumbersome. Simple type casts fail because the compiler assumes any refinement is impossible.

Neither of these is great. Here's a simplified example of a real bug:

interface AccountData {
  id: number;
}
function reifyAccount(data: AccountData);

function readUserInput(): any;

const data = readUserInput(); // data is any
reifyAccount(data); // oops, what if data doesn't have .id or it's not a number?

The version using Object is cumbersome:

function readUserInput(): Object;

const data = readUserInput();
reifyAccount(data); // compile error - GOOD!
if (data as AccountData) // compile error - debatably good or cumbersome
  reifyAccount(data);
if (typeof data.id === 'number') // compile error - not helpful
  reifyAccount(data as AccountInfo);
if (typeof (data as any).id === 'number') // CUMBERSOME and error-prone
  reifyAccount((data as any) as AccountInfo); // still CUMBERSOME and error-prone

With the proposed unknown type;

function readUserInput(): unknown;

const data = readUserInput(); // data is unknown
if (typeof data.id === 'number') // compiles - GOOD - refines data to {id: number} extends unknown
  reifyAccount(data); // data matches {id: number} aka AccountData - SAFE
@seanmiddleditch seanmiddleditch changed the title less-permissive alternative to any unknown: less-permissive alternative to any Sep 5, 2016
@DanielRosenwasser DanielRosenwasser added Suggestion An idea for TypeScript In Discussion Not yet reached consensus labels Sep 5, 2016
@ahejlsberg
Copy link
Member

ahejlsberg commented Sep 6, 2016

The idea of introducing a property in the refined type after offering proof of its existence and type in a type guard is definitely interesting. Since you didn't mention it I wanted to point out that you can do it through user-defined type guards, but it obviously takes more typing than your last example:

interface AccountData {
  id: number;
}

function isAccountData(obj: any): obj is AccountData {
    return typeof obj.id === "number";
}

declare function reifyAccount(data: AccountData): void;

declare function readUserInput(): Object;

const data = readUserInput();  // data is Object
if (isAccountData(data)) {
    reifyAccount(data);  // data is AccountData
}

The advantage to this approach is that you can have any sort of logic you want in the user-defined type guard. Often such code only checks for a few properties and then takes it as proof that the type conforms to a larger interface.

@dead-claudia
Copy link

I like the idea of differentiating "trust me, I know what I'm doing" from "I don't know what this is, but I still want to be safe". That distinction is helpful in localizing unsafe work.

@yortus
Copy link
Contributor

yortus commented Sep 6, 2016

For anyone interested, there's a good deal of related discussion about the pros/cons of any and {} in #9999. The desire for a distinct unknown type is mentioned there, but I really like the way @seanmiddleditch has presented it here. I think this captures it brilliantly:

Where any is the escape hatch out of the type system, unknown is the well-guarded and regulated entrance into the type system.

Being able to express a clear distinction between trusted (any) and untrusted (unknown) data I think could lead to safer coding and clearer intent. I'd certainly use this.

@dead-claudia
Copy link

dead-claudia commented Sep 6, 2016

I'll also point out that some very strongly typed languages still have an escape hatch bottom type themselves for prototyping (e.g. Haskell's undefined, Scala's Nothing), but they still have a guarded entrance (Haskell's forall a. a type, Scala's Any). In a sense, any is TypeScript's bottom type, while {} | void or {} | null | undefined (the type of unknown in this proposal) is TypeScript's top type.

I think the biggest source of confusion is that most languages name their top type based on what extends it (everything extends Scala's Any, but nothing extends Scala's Nothing), but TypeScript names it based on what it can assign to (TypeScript's any assigns to thing, but TypeScript's {} | void only assigns to {} | void).

@yortus
Copy link
Contributor

yortus commented Sep 6, 2016

@isiahmeadows any is universally assignable both to and from all other types, which in the usual type parlance would make it both a top type and a bottom type. But if we think of a type as holding a set of values, and assignability only being allowed from subsets to supersets, then any is an impossible beast.

I prefer to think of any more like a compiler directive that can appear in a type position that just means 'escape hatch - don't type check here'. If we think of any in terms of it's type-theory qualities, it just leads to contradictions. any is a type only by definition, in the sense that the spec says it is a type, and says that it is assignable to/from all other types.

@dead-claudia
Copy link

Okay. I see now. So never is the bottom type. I forgot about any being
there for supporting incremental typing.

On Mon, Sep 5, 2016, 23:04 yortus notifications@github.com wrote:

@isiahmeadows https://github.com/isiahmeadows any is universally
assignable both to and from all other types, which in the usual type
parlance would make it both a top type and a bottom type. But if we
think of a type as holding a set of values, and assignability only being
allowed from subsets to supersets, then any is an impossible beast.

I prefer to think of any more like a compiler directive that can appear
in a type position that just means 'escape hatch - don't type check here'.
If we think of any in terms of it's type-theory qualities, it just leads
to contradictions. any is a type only by definition, in the sense that
the spec says it is a type, and says that it is assignable to/from all
other types.


You are receiving this because you were mentioned.

Reply to this email directly, view it on GitHub
#10715 (comment),
or mute the thread
https://github.com/notifications/unsubscribe-auth/AERrBC3YMJnttuqXk4Dq6iTh9VC7orY2ks5qnNg7gaJpZM4J1Wzb
.

@RyanCavanaugh
Copy link
Member

This could use clarification with some more examples -- it's not clear from the example what the difference between this type and any are. For example, what's legal to do with any that would be illegal to do with unknown?

With {}, any, {} | null | undefined, the proposed but unimplemented object, and never, most use cases seem to be well-covered already. A proposal should outline what those use cases are and how the existing types fail to meet the needs of those cases.

@saschanaz
Copy link
Contributor

saschanaz commented Sep 28, 2016

@RyanCavanaugh

If I understand correctly:

let x;
declare function sendNumber(num: number);

sendNumber(x); // legal in any
sendNumber(x); // illegal in unknown and {}

if (typeof x.num === "number") {
  sendNumber(x.num); // legal in any and unknown, illegal in {}
}

BTW, what does the proposed-but-unimplemented object type do? I haven't seen or read about it.

@dead-claudia
Copy link

dead-claudia commented Sep 29, 2016

@saschanaz Your understanding matches mine, too.

declare function send(x: number)
let value: unknown

send(value) // error
send(value as any) // ok
if (typeof value === "number") {
  send(value) // ok
}

On Wed, Sep 28, 2016, 19:11 Kagami Sascha Rosylight <
notifications@github.com> wrote:

@RyanCavanaugh https://github.com/RyanCavanaugh

If I understand correctly:

let x;declare function sendNumber(num: number);

sendNumber(x); // legal in any
sendNumber(x); // illegal in unknown and {}
if (typeof x.num === "number") {
sendNumber(x.num); // legal in any and unknown, illegal in {}
}


You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
#10715 (comment),
or mute the thread
https://github.com/notifications/unsubscribe-auth/AERrBO-yD4Qdc605NubmW6yKrBXH5ZFTks5quvQUgaJpZM4J1Wzb
.

@mhegazy
Copy link
Contributor

mhegazy commented Sep 29, 2016

I think the request here is for unknown to be { } | null | undefined, but be allowed to "evolve" as you assert/assign into it.

@mihailik
Copy link
Contributor

mihailik commented Oct 26, 2016

Suggestion: drop unknown keyword, and apply "the ability to evolve" feature to {} itself.

That would limit cognitive load and proliferation of 'exception' types like never, unknown, any, void etc. But also it would force people to spell null-ability when it comes to the alien data.

Evolvability

The currently existing {} type, and intersection with it will get an extra feature, being "evolvable".

Evolvable means you can probe its properties liberally without casting. Probing means accessing property in special known positions:

var bla: {};
if (bla.price) console.log("It's priced!");   // GOOD, we **probed** price
console.log(bla.price);                       // BAD, we're **using** price, which isn't safe
if (bla.price) console.log(bla.price);        // GOOD, we **probed**,  then we can use

Probing works very similar to type assertions, and in a conventional JS coding style too. After a property is probed, the type of the expression changes to {} & { property: any; }, allowing immediate use of the property as in the last line of the example above.

I suggest these three supported ways of probing:

// non-null probing,      asserts {} & { property: any; }
if (bla.price) console.log("priced!");

// property probing,     asserts {} & { property: any | null | undefined }
if ('price' in bla) console.log("priced!");undefined; }

// typed probing,         asserts {} & { property: type; }
if (typeof bla.price==='number') console.log("priced!");}

// custom assert probing, asserts {} & { property: type; }
if (isFinite(bla.price)) console.log("priced!");

Intersection

It's crucial to allow "evolability" to more than just one selected type, but intersections too. Consider multi-property asserts that naturally come out of it:

if (bla.price && bla.articleId && bla.completed)
  acknowledgeOrder(bla);

No unknowns please

Lastly I want to highlight the real danger of unknown keyword:

unknown
undefined

Those two are way too similar, and be confused in all sorts of situations. Mere typos would be a big problem in itself. But factor in genuine long-term misconceptions this similarity would inevitably breed.

Picking another, less similar keyword might help, but going straight for an existing syntax is much better.

The point of {} in the first place is to mark values we don't know properties of. It's not for objects without properties, it's objects with unknown properties. Nobody really uses empty objects except in some weirdo cases.

So this extra sugar added on {} would most of the time be a welcome useful addition right where it's helpful. If you deal with unknown-properties case, you get that probing/assertion evolvability intuitive and ready. Neat?

UPDATE: replaced unions with intersections up across the text, my mistake using wrong one.*

@saschanaz
Copy link
Contributor

I think changing existing behavior is too surprising.

let o1 = {};
o1.foo // okay

let o2 = { bar: true };
o1.foo // suddenly not okay :/

@mihailik
Copy link
Contributor

No, the first is not OK either — you're not probing there (for non-null probing it would require a boolean-bound position to qualify).

With the probing definitions outlined above, compiler still errors on genuine errors, but it would handle probing/evolving neatly without excessive verbosity.

@mihailik
Copy link
Contributor

Also note that {} naturally fits with strictNullChecks story — and with my suggestions it continues to do so neatly. Meaning it follows stricter checks when option is enabled, and gets relaxed when it isn't.

Not necessary the case with unknown:

var x: unknown;
x = null    // is it an error?   you would struggle to guess, i.e. it impedes readability

var x: {};
x = null;    // here the rules are well-known

@dead-claudia
Copy link

I really don't like this. It removes a level of type safety in the
language, and it would especially show up when you have large numbers of
boolean flags on an object. If you change the name of one, you might miss
one and TypeScript wouldn't tell you that you did, because it's just
assuming you're trying to narrow the type instead.

On Wed, Oct 26, 2016, 08:52 mihailik notifications@github.com wrote:

Suggestion: drop unknown keyword, and apply "the ability to evolve"
feature to {} itself.

That would limit cognitive load and proliferation of 'exception' types
like never, unknown, any, void etc. But also it would force people to
spell null-ability when it comes to the alien data.
Evolvability

The currently existing {} type, and union with it will get an extra
feature, being "evolvable".

Evolvable means you can probe its properties liberally without casting.
Probing means accessing property in special known positions:

var bla: {};if (bla.price) console.log("It's priced!"); // GOOD, we probed price
console.log(bla.price); // BAD, we're using price, which isn't safeif (bla.price) console.log(bla.price); // GOOD, we probed, then we can use

Probing works very similar to type assertions, and in a conventional JS
coding style too. After a property is probed, the type of the expression
changes to {} | { property: any; }, allowing immediate use of the
property as in the last line of the example above.

I suggest these three supported ways of probing:

// non-null probing, asserts {} | { property: any; }if (bla.price) console.log("priced!");if ('price' in bla) console.log("priced!"); // property probing, asserts {} | { property: any | null | undefined; }if (typeof bla.price==='number') console.log("priced!"); // typed probing, asserts {} { property: type; }if (isFinite(bla.price)) console.log("priced!"); // custom assert probing, asserts {} { property: type; }


You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
#10715 (comment),
or mute the thread
https://github.com/notifications/unsubscribe-auth/AERrBDdp6AGkgsZTHdn8g0pzWMBdS1-gks5q300RgaJpZM4J1Wzb
.

@mihailik
Copy link
Contributor

I think you're missing a point @isiahmeadows — "evolvability" is only enabled for {} and its intersections.

Most normal types won't have that feature: number, Array, MyCustomType, or even { key: string; value: number; } are not "evolvable".

If you have an example with boolean flags, let's see how it works.

@dead-claudia
Copy link

A top type like this unknown would be immensely useful for type safety reasons. It gets old typing {} | void each time.

@simonbuchan
Copy link

simonbuchan commented Feb 8, 2018

Maybe

  • if (x > 0) is not useful:
    • "-1" > 0 -> false
    • "1" > 0 -> true
    • null > 0 -> false
    • null > -1 -> true
    • new Date(0) > 0 -> false
    • new Date(1) > 0 -> true
    • and so on...
      At best you could say an inequality with 0 could exclude undefined | null. Yay.
  • x === y - no opinion, seems useless though.
  • if (x) implies x: true | number | string | object i.e. the truthy type
  • if (!x) implies x: undefined | null | false | 0 | "" i.e. the falsey type (what about NaN?)
  • I would want x === 'foo' || x === 'bar' to imply x: 'foo' | 'bar'
// SVG-ish...
type FillMode = 'nonzero' | 'evenodd'
type Shape = { d: PathCommand[]; fill: Fill; fillMode?; FillMode; ... };

function validateShape(input: string): Shape {
  const value = JSON.parse(input);
  ...
  if ('fillMode' in value) {
    if (value.fillMode !== 'nonzero' && value.fillMode !== 'evenodd')
      throw new Error('Bad Shape fillMode');
  }
  ...
  return value; // no cast needed iff checks are sufficient
}

@jack-williams
Copy link
Collaborator

jack-williams commented Feb 15, 2018

Do we need to do anything with x === y

I think this should be an error when one is unknown and the other is T, for some type-variable.

No it should have the same behaviour as x == y, when one is {} | undefined | null, and the other is T. I forgot that there are different cases for when the concrete type contains {}, instead of just being number | boolean, for example.

In my head those cases should be the same but they aren't, hence my initial incorrect comment.

In this example:

function fn(x: unknown) {
    if (Array.isArray(x)) {
        // OK, or no?
        const arr: any[] = x;
    }
}

Does x get narrowed to unknown[]?

@Conaclos
Copy link

Conaclos commented Mar 9, 2018

@RyanCavanaugh
I think that the meaning of unknown & T is not T. In contrast to T, you can perform type tests on unknown & T:

function f <T> (x: unknown & T): x is ComplexType & T {
    // Check for ComplexType.
}

@felipeochoa
Copy link

I've had a decent amount of success with the following definition:

type mixed<K extends string = never> = string | boolean | number | null | undefined
    | mixedArray<K> | mixedObject<K>;
interface mixedArray<K extends string> extends Array<mixed<K>> {}
type mixedObject<K extends string> = {[key in K]?: mixed};

This allows narrowing using typeof for the core types and Array.isArray to get back a mixed[]. You can then narrow the mixed[] to tuples or specific types of arrays. You can also narrow to enums by first narrowing to string/number and then narrowing the allowed values.

Type narrowing using "a" in x as described in #10485 does not work. Instead, you need to provide a generic type K that is the union of all the keys in the structure you're checking against (see below). You can then "build up" your target object one property at a time by checking that the properties are not undefined.

type DeepKeyOf<T> =
    T extends any[] ? never
    : T extends object ? keyof T | {[K in keyof T]: DeepKeyOf<T[K]>}[keyof T]
    : never;

You could extend this further and add another generic parameter to the mixed* types to support instanceof narrowing, but I was focused on the JSON case.


To throw my 2c in here: it would be nice to have a built-in implementation of mixed so that library authors wouldn't use any when they really meant unknown. It would obviously be ideal if there were a built-in mixed (or whatever name) that didn't need the generic hackery that this implementation uses. If #10485 were extended to work on {}, narrowing it to {a: mixed}, the above implementation of mixed minus the generic params would cover the majority of use-cases.

@SimonMeskens
Copy link

I'm closing #23838 and posting my thoughts in here. Proper top would only be useful if:

  • Everything is assignable to unknown
  • unknown can be assigned to nothing
    unknown & T = T
    unknown | T = unknown
    unknown extends T ? true : false = false (in other words, follow assignability rules)

This agrees exactly with Ryan's post.

No matter what fringe use cases, these kinds of types are used often inside of very complex mapped conditionals, etc, and every special case needs to be checked, so it's important that the type acts very "purely mathematical". There's some weird behavior with never that doesn't fit the name at times, but the reason it's usable, is because it's pure. I use never more than any other type because of that reason.

@Conaclos

function f <T> (x: T): x is ComplexType & T {
    // Check for ComplexType.
}

This code is equivalent to yours. No need for unknown, except as a hint to the programmer that there might be more to the type. That small hint is not worth making the type useless as a top type.

@pelotom
Copy link

pelotom commented May 3, 2018

No matter what fringe use cases, these kinds of types are used often inside of very complex mapped conditionals, etc, and every special case needs to be checked, so it's important that the type acts very "purely mathematical".

This.

@dead-claudia
Copy link

@SimonMeskens

No matter what fringe use cases, these kinds of types are used often inside of very complex mapped conditionals, etc, and every special case needs to be checked, so it's important that the type acts very "purely mathematical".

100% agreed. I couldn't tell you how many times I've had to alias type unknown = {} | null | undefined | void just to properly and safely type something. It comes up a lot when writing highly generic code, and in probably about 50% of the TypeScript projects I've worked on have had a legitimate use for this.

Please, can we add unknown as a proper top type, so I don't have to frequently type out that alias in so many TS projects? (It'd also open the door to better type definition writing.)

@jack-williams
Copy link
Collaborator

Is it safe to narrow a value of type type unknown using in?

const x: unknown = undefined;
if ("p" in x) { 
  // Statically (as proposed) x : { p: unknown }
  // Dynamically: TypeError: Cannot use 'in' operator to search for 'p' in undefined
}

As x can be anything it can be undefined, which would cause a run-time error when applying in.

Please laugh at me if I missed something obvious.

@pelotom
Copy link

pelotom commented May 4, 2018

@jack-williams for this to be type safe I think it should be required that you ensure it's an object:

declare const x: unknown;
if (x instanceof Object) { // narrows typeof x to {}
  if ('p' in x) { // narrows typeof x to { p: unknown }
    const { p } = x;
  }
}

@simonbuchan
Copy link

simonbuchan commented May 4, 2018

in is legal, though not particularly useful for sane code, for boolean, number, string and symbol too.
(Incidentally, it would be nice if TS had something like a primitive type that excluded undefined, null and object 🤷)
That said, if (x && 'p' in x) is natural and safe, so I'd like support for that.

@pelotom
Copy link

pelotom commented May 4, 2018

Sure, I was just giving one example of a narrowing that would make the check safe. Checking that it’s a string, number, etc. or just truthy should all be valid ways to narrow it sufficiently.

@jcready
Copy link

jcready commented May 4, 2018

@simonbuchan if (x && typeof x === 'object' && 'p' in x) is safe, but if (x && 'p' in x) isn't.

@felixfbecker
Copy link
Contributor

@simonbuchan in is not legal for anything other than objects.

'p' in true
TypeError: right-hand side of 'in' should be an object, got boolean[Learn More] debugger eval code:1:1
'p' in 'str'
TypeError: cannot use 'in' operator to search for 'p' in 'str' debugger eval code:1:1
'p' in 123
TypeError: right-hand side of 'in' should be an object, got number[Learn More] debugger eval code:1:1
'p' in Symbol()
TypeError: right-hand side of 'in' should be an object, got symbol[Learn More]

@simonbuchan
Copy link

My mistake! (I was sure I checked that? Maybe I got mixed up with if (x && x.p) always being safe)
So yeah, type checking unknown accesses would be quite handy 😂

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Fixed A PR has been merged for this issue In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests