microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
101.3k stars 12.53k forks source link

Add spread/rest higher-order types operator #10727

Open sandersn opened 8 years ago

sandersn commented 8 years ago

The spread type is a new type operator that types the TC39 stage 3 object spread operator. Its counterpart, the difference type, will type the proposed object rest destructuring operator. The spread type { ...A, ...B } combines the properties, but not the call or construct signatures, of entities A and B.

The pull request is at #11150. The original issue for spread/rest types is #2103. Note that this proposal deviates from the specification by keeping all properties except methods, not just own enumerable ones.

Proposal syntax

The type syntax in this proposal differs from the type syntax as implemented in order to treat spread as a binary operator. Three rules are needed to convert the { ...spread1, ...spread2 } syntax to binary syntax spread1 ... spread2.

  1. { ...spread } becomes {} ... spread.
  2. { a, b, c, ...d} becomes {a, b, c} ... d
  3. Multiple spreads inside an object literal are treated as sequences of binary spreads: { a, b, c, ...d, ...e, f, g} becomes {a, b, c} ... d ... e ... { f, g }.

    Type Relationships

    • Identity: A ... A ... A is equivalent to A ... A and A ... A is equivalent to {} ... A.
    • Commutativity: A ... B is not equivalent to B ... A. Properties of B overwrite properties of A with the same name in A ... B.
    • Associativity: (A ... B) ... C is equivalent to A ... (B ... C). ... is right-associative.
    • Distributivity: Spread is distributive over |, so A ... (B | C) is equivalent to A ... B | A ... C.

      Assignment compatibility

    • A ... B is assignable to X if the properties and index signatures of A ... B are assignable to those of X, and X has no call or construct signatures.
    • X is assignable to A ... B if the properties and index signatures of X are assignable to those of A ... B.

      Type parameters

A spread type containing type parameters is assignable to another spread type if the type if the source and target types are both of the form T ... { some, object, type } and both source and target have the same type parameter and the source object type is assignable to the target object type.

Type inference

Spread types are not type inference targets.

Properties and index signatures

In the following definitions, 'property' means either a property or a get accessor.

The type A ... B has a property P if

  1. A has a property P or B has a property P, and
  2. Either A.P or B.P is not a method.

In this case (A ... B).P has the type

  1. Of B.P if B.P is not optional.
  2. Of A.P | B.P if B.P is optional and A has a property P.
  3. Of A.P otherwise.

private, protected and readonly behave the same way as optionality except that if A.P or B.P is private, protected or readonly, then (A ...B).P is private, protected or readonly, respectively.

Index signatures

The type A ... B has an index signature if A has an index signature and B has an index signature. The index signature's type is the union of the two index signatures' types.

Call and Construct signatures

A ... B has no call signatures and no construct signatures, since these are not properties.

Precedence

Precedence of ... is higher than & and |. Since the language syntax is that of object type literals, precedence doesn't matter since the braces act as boundaries of the spread type.

Examples

Taken from the TC39 proposal and given types.

Shallow Clone (excluding prototype)

let aClone: { ...A } = { ...a };

Merging Two Objects

let ab: { ...A, ...B } = { ...a, ...b };

Overriding Properties

let aWithOverrides: { ...A, x: number, y: number } = { ...a, x: 1, y: 2 };
// equivalent to
let aWithOverrides: { ...A, ...{ x: number, y: number } } = { ...a, ...{ x: 1, y: 2 } };

Default Properties

let aWithDefaults: { x: number, y: number, ...A } = { x: 1, y: 2, ...a };

Multiple Merges

// Note: getters on a are executed twice
let xyWithAandB: { x: number, ...A, y: number, ...B, ...A } = { x: 1, ...a, y: 2, ...b, ...a };
// equivalent to
let xyWithAandB: { x: number, y: number, ...B, ...A } = { x: 1, ...a, y: 2, ...b, ...a };

Getters on the Object Initializer

// Does not throw because .x isn't evaluated yet. It's defined.
let aWithXGetter: { ...A, x: never } = { ...a, get x() { throw new Error('not thrown yet') } };

Getters in the Spread Object

// Throws because the .x property of the inner object is evaluated when the
// property value is copied over to the surrounding object initializer.
let runtimeError: { ...A, x: never } = { ...a, ...{ get x() { throw new Error('thrown now') } } };

Setters Are Not Executed When They're Redefined

let z: { x: number } = { set x() { throw new Error(); }, ...{ x: 1 } }; // No error

Null/Undefined Are Ignored

let emptyObject: {} = { ...null, ...undefined }; // no runtime error

Updating Deep Immutable Object

let newVersion: { ...A, name: string, address: { address, zipCode: string }, items: { title: string }[] } = {
  ...previousVersion,
  name: 'New Name', // Override the name property
  address: { ...previousVersion.address, zipCode: '99999' } // Update nested zip code
  items: [...previousVersion.items, { title: 'New Item' }] // Add an item to the list of items
};

Note: If A = { name: string, address: { address, zipCode: string }, items: { title: string }[] }, then the type of newVersion is equivalent to A

Rest types

The difference type is the opposite of the spread type. It types the TC39 stage 3 object-rest destructuring operator. The difference type rest(T, a, b, c) represents the type T after the properties a, b and c have been removed, as well as call signatures and construct signatures.

A short example illustrates the way this type is used:

/** JavaScript version */
function removeX(o) {
  let { x, ...rest } = o;
  return rest;
}

/** Typescript version */
function removeX<T extends { x: number, y: number }>(o: T): rest(T, x) {
  let { x, ...rest }: T = o;
  return rest;
}

Type Relationships

The type rest(A, P) removes P from A if it exists. Otherwise, it does nothing.

Call and Construct signatures

rest(A) does not have call or construct signatures.

Precedence

Difference types have similar precedence to - in the expression grammar, particularly compared to & and |. TODO: Find out what this precedence is.

sandersn commented 8 years ago

@ahejlsberg @RyanCavanaugh can you take a look at the spread type section and see if there's something I forgot to take into account? The rest type section isn't really done because I want to talk about the problems in person.

mhegazy commented 8 years ago

You mention "own" properties; the type system today does not have any definition for this, and you do not define it clearly. I would say we should just ignore this for now.

sandersn commented 8 years ago

Defining 'own' as 'not inherited' gets us pretty close, since we have a good definition of inherited already. Actually, if I recall, the PR implements 'enumerable' as 'things that are not methods', which is pretty close in a similar way. There will still be false positives, but not from ES6-style class hierarchies.

mhegazy commented 8 years ago

Interfaces are the issue. it is common for ppl to define interfaces as hierarchy to share declarations. so are these "own" or not?

sandersn commented 8 years ago

I'm having trouble coming up with an example. Here's what I got so far.

interface B {
  b: number
}
interface I extends B {
  a: number
}
class C implements I { // error, C not assignable to I
  c: number
}

let ib: { ...C } = { ...new C() }
let ib2: { a: number, b: number, c: number} = ib; // error, C and { ...C } don't have a or b.

I gets b from B, but in what circumstance would b not be treated as an own property? In this example, C doesn't have a OR b, so neither does { ... C }. But that's already how classes work.

If you have an interface hierarchy that matches the class hierarchy, then inheritance still works as a definition for own types:

class K implements B {
  b: number
}
class C extends K implements I {
  a: number
}
let a: { ... C } = { ... new C() }
let ab: { a: number, b: number } = a; // error, { ... C } doesn't have b.
mhegazy commented 8 years ago

I meant speading a value whose tupe is an interface:

interface B {
  b: number
}
interface I extends B {
  a: number
}

var i: I;

var x = {...i}; // is x {a: number} or {a:number, b:number}?
sandersn commented 8 years ago

@DanielRosenwasser and I came up with some counterexamples for both own and enumerable properties. They are below. Basically, since we don't track this information in the type system, we need either

  1. start tracking it
  2. be over-strict
  3. be over-permissive
  4. use heuristics based on information we have and be wrong some of the time.

I'm not sure how hard (1) is.

interface I {
  a: number;
  b: number;
}
class K {
  b = 12
}
declare class C extends K implements I {
  a = 101
}
let c: I = new C();
let i: I = { ...c } // no error, but should be because 'b' was missed
i.b // error at runtime: 'b' was not an own property and got removed.

The missing piece in this example is that C instances are assignable to I but lose the class inheritance information. Unfortunately this loss of information extends to enumerability as well:

interface I {
  a: number;
  f: () => void;
}
class C implements I {
  a = 12;
  f() { };
}
let c: I = new C();
let i: I = { ... c }; // no error, but should be because f is missed
i.f() // error at runtime: f was not an enumerable property

In this example, I specifies a function property but C provides a method, which is legal in TypeScript. When c gets spread, though, f drops out and i.f() will fail at runtime.

sandersn commented 8 years ago

I updated the proposal to not specify own, enumerable properties. I should add a note that we deviate from the stage 2 spec, though.

wclr commented 8 years ago

Subtraction types would really be very nice to have.

saschanaz commented 8 years ago

For the subtraction type example, shouldn't it be error?

/** Typescript version */
function removeX<T extends { x: number, y: number }>(o: T) {
  let { x, ...rest }: T - { x: number } = o; // Error?: Type "T - { x: number }" has no property "x"
  return rest;
}

// Did you intend this?
function removeX<T extends { x: number, y: number }>(o: T): T - { x: number } {
  let { x, ...rest } = o;
  return rest;
}
sandersn commented 8 years ago

Yes, thanks. I'll update the proposal.

felixfbecker commented 8 years ago

This is pretty awesome! One thing that is not clear to me: What will spreading a class do? Will it take the instance properties or the static properties? I would say the first, so to get the static properties is spreading like ...(typeof MyClass) supported?

The use case is as follows: methods/constructors that take an object literal as an argument, for example Sequelize (ORM):

class User extends Model {
  public id?: number;
  public name?: string;
  constructor(values?: ...User);
  static update(changes: ...User): Promise<void>;
  static findAll(options?: {where?: ...User}): Promise<User[]>;
}

What is limiting here of course still is that there is no way to mark all the properties as optional, but in the case of Sequelize all properties can be undefined because you can choose to not eager-load all attributes.

It would also be nice to know if a union type of a spread type and an index signature type is allowed:

type UserWhereOptions = ...User & {
  [attribute: string]: { $and: UserWhereOptions } | { $or: UserWhereOptions } | { $gt: number };
}
class User extends Model {
  static findAll(options?: {where?: UserWhereOptions}): Promise<User[]>;
}

Regarding subtraction types, isn't a subtraction a mathematical operation on numbers? Shouldn't this really be the difference operator \?

sandersn commented 8 years ago
  1. You get the instance properties. That's because the spec says that you get the own properties, and instance properties are the closest concept that the compiler tracks.

    class C {
     static s: number = 12;
     i: number = 101;
    }
    let c = new C();
    let spi: { ...C } = { ... c };
    spi.i // instance properties 
    let sps: { ...typeof C } = { ...C };
    sps.s; // static properties
  2. It sounds like you would be interested in the partial type operator.
  3. You can add index signatures directly to your spread types:

    type UserWhereOptions = { ...User, [attribute: string]: { $and: UserWhereOptions } };
  4. The syntax is up in the air right now. The spread type PR implements an object-literal-like syntax but it's not final.
felixfbecker commented 8 years ago
  1. 👍
  2. Looks awesome, and work very well if it can be combined with the rest spread
  3. 👍

If A and B are sets, then the relative complement of A in B,[1] also termed the set-theoretic difference of B and A,[2] is the set of elements in B but not in A.

The relative complement of A in B is denoted B \ A according to the ISO 31-11 standard. It is sometimes written B - A, but this notation is ambiguous, as in some contexts it can be interpreted as the set of all elements b - a, where b is taken from B and a from A.

https://en.wikipedia.org/wiki/Complement_(set_theory)
What speaks against \ is that a lot of languages use - as an operator, not everyone is familiar with set theory, backslash has a meaning of "escaping" stuff and JavaScript is known for abusing arithmetic operators already (concatenation should really be ., not +). But maybe we can at least not call it subtraction type, but difference type in the proposal (you wouldn't call a string concatenation a string addition, just because it uses the +)

sandersn commented 8 years ago

(4) Good idea. Done.

Lenne231 commented 8 years ago

I think it would be a better idea to use the syntax flowtype uses (#2710), i.e. $Diff<A, B> instead of - or \. There are other issues like #4889 where we can use the same syntax, i.e. $Partial<T> or $Shape, instead of introducing a new syntax for each case.

diff<A,B>, partial<T> or shape<T>would be ok as well.

felixfbecker commented 8 years ago

I'm -1 on the flow syntax:

sandersn commented 8 years ago

I updated the proposal to use a binary syntax A ... B. This makes the specification simpler. TypeScript will continue to use object type literal syntax to specify spread types: { ...T, ...U } instead of T ... U.

sandersn commented 8 years ago

I updated the proposal to reflect recent changes in the PR.

Specifically, assignability got stricter, spreads are no longer type inference targets, and index signatures only spread if both sides have an index signature.

mhegazy commented 8 years ago

We would like to pursue mapped types to model the type space for these changes.

christyharagan commented 7 years ago

Hi all,

Apologises if this is the wrong issue to to raise this question; I'll move as necessary if it is.

However, I've been tracking this work with keen interest and was wondering if there will be a similar type for Array or Tuple types. I know #5453 tracks something similar, although this seems to be specifically for function calls (which sadly seems blocked due to complexities arising from the various use-cases).

Essentially, the use-case I'm looking to solve is the ability to type an Array/Tuple where the first n-elements are typed, and then the remaining elements are typed as an unbounded list. To be more precise (and correct my loose language), consider:

type AT_LEAST_ONE_ELEMENT = [string, ...string]

// To support the case where a CSV row is parsed, and the first 4 columns are known, but the remaining columns (if any) will be strings
type FLEXIBLE_CSV_ARRAY = [string, number, string, boolean, ...string]

For a few bonus points, it would be great if intersections worked like:

type ONE_ELEMENT = [string]
type MANY_ELEMENTS = string[]

type AT_LEAST_ONE_ELEMENT = ONE_ELEMENT & MANY_ELEMENTS // === [string ...string]
sandersn commented 7 years ago

Object spread types work with objects, and this proposal sounds more like a modification of tuples to add an array spread type for working with tuples. A lot like #6229, in fact, "Strict and open length tuples", but adding to that proposal to allow the specification of an array-spread type at the end.

andersekdahl commented 7 years ago

I can't quite read from the proposal if I'd be able to do:

type A = {
  x: string;
  y: string;
}

type B = {
  y: string;
}

// C would only contain x
type C = rest(A, B);

Of if the last arguments to rest have to be explicit property names?

sandersn commented 7 years ago

Almost; currently it's rest(A, keyof B) with the restriction that B has to be an object type. It can't be a type parameter.

The updated proposal is with the PR at #13470. I need to update this issue to match it.

aikoven commented 7 years ago

There's a hack that allows to build the spread type:

interface A {
  method(): void;
  prop: number;
}

interface B {
  anotherMethod(): void;
  anotherProp: number;
}

const spread = {...({} as A & B)};
type ABSpread = typeof spread;

declare const s: ABSpread;

s.prop;  // ok
s.method;  // error
s.anotherProp;  // ok
s.anotherMethod;  // error
wclr commented 7 years ago

@sandersn Is it going to land anytime soon?

Do I understand correctly that this should allow to have normally merged object types:

type A = { x: string, y: string }
type B = { x: number, z: number}
type C = {...A, ...B} // {x: number, y: string, z: number}

?

sandersn commented 7 years ago

It's not on our immediate list of things to ship.

You are correct, if you spread two object types, you get a new object type instead of a spread type.

mikew commented 7 years ago

Is this what's causing this code to have no issues? I would expect an error when setting bar in doSomething.

interface MyObj {
  foo?: number,
}

function doSomething(state: MyObj): MyObj {
  return { ...state, bar: 42 }
}
sandersn commented 7 years ago

Nope, there are no generic types in your code. That is just a legal spread. Objects with spreads don't check for excess properties. #12997 (some design notes) has some explanation for this at the end.

Jessidhia commented 7 years ago

Will having spread types allow for excess property checking in spreads?

mhegazy commented 7 years ago

That is not related.

sandersn commented 7 years ago

@Kovensky #12936 tracks exact types, which is the feature you want.

ssynix commented 7 years ago

Does this PR support the use case shown here? https://github.com/tc39/proposal-object-rest-spread/issues/45

Basically conditional assignment of object keys using object spread. I see that "Null/Undefined Are Ignored", but does that extend to false as well?

sandersn commented 7 years ago

Currently, the emitted Javascript is correct but the compiler issues an error. We didn't think of the pattern you linked to, and thought that people would like to know if they mistakenly spread a boolean where they thought they had an object type.

We could obviously remove the error if lots of people use this pattern. I recommend that you create a new issue, label it Suggestion, and try to get upvotes for it. That's typically how we gather evidence for a change.

KiaraGrouwstra commented 7 years ago

Syntax support aside, I think we could type the operations covered by this syntax without special types:

lednhatkhanh commented 6 years ago

Still happening in 2.6.2...

rozzzly commented 6 years ago

@lednhatkhanh is that a question or a statement? I'd like to know if the spread operator is coming too..

saeedtabrizi commented 6 years ago

Hi @mhegazy , @sandersn . Is there any update or good news for this issue ? I see only @mhegazy changes milestones in past months . Thanks

Dru89 commented 6 years ago

@burabure This issue is specifically about type spreads, I believe.

What you're doing should already work with the spread operator, I think.

burabure commented 6 years ago

@Dru89 I'm such a dummy, I confused the issue XD

volgar1x commented 6 years ago

This is still happening as of TypeScript 2.8.3.

mshoho commented 6 years ago

Hi, what is the status of it?

I would really love to have:

function merge<A, B>(a: A, b: B): A {
    return { ...a, ...b};
}

So that the compiler typechecks if the object of type B can be merged into A.

dead-claudia commented 6 years ago

@mshoho You mean function merge<A, B>(a: A, b: B): A & B?

mshoho commented 6 years ago

No. Something like:

interface AA {
    prop1: string;
    prop2?: number;
    prop3?: boolean;
}

interface BB {
    prop2?: number;
    prop3?: boolean;
}

interface CC {
    prop2?: number;
    prop4?: string;
}

function merge<A, B>(a: A, b: B): A {
    return { ...a, ...b };
}

let a: AA = { prop1: 'hello', prop2: 1 };
let b: BB = { prop2: 100, prop3: true };
let c: CC = { prop2: 500, prop4: 'world' };

merge(a, b); // ok.
merge(a, c); // compiler error.
pelotom commented 6 years ago

@mshoho you can approximate this with conditional types, only returning a "useful" type in the event that the second argument is compatible with the first:

function merge<A, B>(a: A, b: B): Required<A> extends Required<B> ? A & B : {} {
  return Object.assign({}, a, b) as any;
}

merge(a, b).prop2; // ok.
merge(a, c).prop2; // compiler error.
mshoho commented 6 years ago

@pelotom I have something like this:

function someExternalFunction(a: AA) {
    // Something external, out of my control.
}

// And I need safely typechecked way to:

someExternalFunction(merge(a, b)); // ok.
someExternalFunction(merge(a, c)); // compiler error.

Unfortunately, {} | AA is not assignable to AA.

pelotom commented 6 years ago

@mshoho did you try what I wrote? The result type is not a union...

mshoho commented 6 years ago

@pelotom yes, I've just tried it.

Perhaps because I have a chain of functions with generics:

function process<A, C>(a: A, c: C) {
    someExternalFunction(merge(a, c));
}

I get this for the merge call:

[ts]
Argument of type 'Required<A> extends Required<C> ? A : {}' is not assignable to parameter of type 'AA'.
  Type '{} | A' is not assignable to type 'AA'.
    Type '{}' is not assignable to type 'AA'.
      Property 'prop1' is missing in type '{}'.
pelotom commented 6 years ago

@mshoho ah, yeah, that'd do it. If you don't know what A and C are, I don't think there's a way to ensure they are compatible.

mshoho commented 6 years ago

@pelotom hence my question about the status. Because as far as I understand, my problem will be automagically fixed by this ticket.