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

Add spread/rest higher-order types operator #10727

Open
sandersn opened this issue Sep 6, 2016 · 86 comments
Open

Add spread/rest higher-order types operator #10727

sandersn opened this issue Sep 6, 2016 · 86 comments
Labels
Revisit An issue worth coming back to Suggestion An idea for TypeScript
Milestone

Comments

@sandersn
Copy link
Member

sandersn commented Sep 6, 2016

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

  • rest(A) is not equivalent to A because it is missing call and construct signatures.
  • rest(rest(A)) is equivalent to rest(A).
  • rest(rest(A, a), b) is equivalent to rest(rest(A, b), a) and rest(A, a, b).
  • rest(A | B, a) is equivalent to rest(A, a) | rest(B, a).

Assignment compatibility

  • rest(T, x) is not assignable to T.
  • T is assignable to rest(T, x) because T has more properties and signatures.

Properties and index signatures

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
Copy link
Member Author

sandersn commented Sep 6, 2016

@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
Copy link
Contributor

mhegazy commented Sep 6, 2016

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
Copy link
Member Author

sandersn commented Sep 6, 2016

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
Copy link
Contributor

mhegazy commented Sep 6, 2016

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

@sandersn
Copy link
Member Author

sandersn commented Sep 6, 2016

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
Copy link
Contributor

mhegazy commented Sep 6, 2016

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
Copy link
Member Author

sandersn commented Sep 6, 2016

@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
Copy link
Member Author

sandersn commented Sep 7, 2016

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
Copy link

wclr commented Sep 11, 2016

Subtraction types would really be very nice to have.

@saschanaz
Copy link
Contributor

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
Copy link
Member Author

Yes, thanks. I'll update the proposal.

@felixfbecker
Copy link
Contributor

felixfbecker commented Sep 26, 2016

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
Copy link
Member Author

  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
Copy link
Contributor

  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
Copy link
Member Author

sandersn commented Sep 27, 2016

(4) Good idea. Done.

@Lenne231
Copy link

Lenne231 commented Sep 27, 2016

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
Copy link
Contributor

I'm -1 on the flow syntax:

  • prefixing stuff with $ is not cool anymore (sorry jQuery :D)
  • the brackets remind me of the syntax for generics, which is confusing.
  • when you leave out the brackets, you are left with just the keyword, which matches the initial proposal for partial
  • for things like diff, A operator B is much easier to read than operator A B. Especially it makes sense when there are equivalent operators in mathematics, and other operators like union already work that way

@sandersn
Copy link
Member Author

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
Copy link
Member Author

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 mhegazy removed this from the TypeScript 2.1 milestone Oct 27, 2016
@craigkovatch
Copy link

craigkovatch commented Jun 29, 2019

Hi, I'm a bit out of my depth with advanced types, but think I'm hitting an error that I think this issue may cover. Please let me know if it does; if it doesn't, I will delete this comment to keep the history clean :)

export interface TextFieldProps {
  kind?: 'outline' | 'line' | 'search';
  label?: React.ReactNode;
  message?: React.ReactNode;
  onClear?: () => void;
  valid?: boolean | 'pending';
}

export function TextFieldFactory<T extends 'input' | 'textarea'>(type: T) {
  return React.forwardRef<T extends 'input' ? HTMLInputElement : HTMLTextAreaElement, TextFieldProps & (T extends 'input' ? React.InputHTMLAttributes<HTMLElement> : React.TextareaHTMLAttributes<HTMLElement>)>((props, ref) => {
    const { className, disabled, id, kind, label, message, onClear, valid, ...inputProps } = props;

inputProps here gives error:
Rest types may only be created from object types.ts(2700)

I think this should work, i.e. inputProps should essentially be of type InputHTMLAttributes & TextareaHTMLAttributes, minus of course the fields that were plucked out.

@kentcdodds
Copy link

Just want to make sure that I'm not missing something. To be clear the PRs referenced, merged, and released above added support for object spread, but did not add support for type spread (as noted in the original post). So today, this is not supported:

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

Is that correct? Today, I'm using this handy utility I copy/pasted from stackoverflow:

// Names of properties in T with types that include undefined
type OptionalPropertyNames<T> =
  { [K in keyof T]: undefined extends T[K] ? K : never }[keyof T];

// Common properties from L and R with undefined in R[K] replaced by type in L[K]
type SpreadProperties<L, R, K extends keyof L & keyof R> =
  { [P in K]: L[P] | Exclude<R[P], undefined> };

type Id<T> = {[K in keyof T]: T[K]} // see note at bottom*

// Type of { ...L, ...R }
type Spread<L, R> = Id<
  // Properties in L that don't exist in R
  & Pick<L, Exclude<keyof L, keyof R>>
  // Properties in R with types that exclude undefined
  & Pick<R, Exclude<keyof R, OptionalPropertyNames<R>>>
  // Properties in R, with types that include undefined, that don't exist in L
  & Pick<R, Exclude<OptionalPropertyNames<R>, keyof L>>
  // Properties in R, with types that include undefined, that exist in L
  & SpreadProperties<L, R, OptionalPropertyNames<R> & keyof L>
  >;


type A = {bool: boolean, str: boolean}
type B = {bool: string, str: string}
type C = Spread<A, B>


const x: C = {bool: 'bool', str: 'true'}

console.log(x)

Is there a better way to accomplish this today? I'm guessing that's what this issue is intended to address, but I'm happy to open a new one if I'm missing something. Thanks!

@sandersn
Copy link
Member Author

Yes. We never found the balance of correctness and usability for spread types that justified their complexity. At the time, React higher-order components also required negated types in order to be correctly typed. Finally, we decided that since we'd been using intersection as a workaround for so long, that it was good enough.

Just glancing at your Spread from stack overflow, it looks a bit simpler than the one I remember Anders writing to test conditional types. So it might be missing something, or it might have been improved since that time.

@kentcdodds
Copy link

Thanks @sandersn! I've also found type-fest which has a Merge utility which does a good job of this as well.

@danielo515
Copy link

This is a very handy functionality to not having to write that many types. Flow allows this so it is doable. If the original proposition is too complex, maybe a more limited one may be implemented, but just ignoring it is not the way to go IMO

@istarkov
Copy link

istarkov commented Oct 19, 2021

@kentcdodds Not sure it works for all cases but shorter

type IntersectPropToNever<A, B> = {
    [a in keyof (A & B)]: (A & B)[a]    
}
type Spread<A, B> = IntersectPropToNever<A, B> & A | B;

type A = {a: string, b: string};
type B = {b: number};

const tt: Spread<A,B> = {a: '', b: 1};
const ee: Spread<B,A> = {a: '', b: '1'};

// error Type 'string' is not assignable to type 'number'.
const tt_err: Spread<A,B> = {a: '', b: '1'};
// error Type 'number' is not assignable to type 'string'.
const ee_err: Spread<B,A> = {a: '', b: 1};
// Property 'b' is missing in type '{ a: string; }'
const ww_err: Spread<A,B> = {a: ''}

TS Playground

@benwiley4000
Copy link

I'll share a very specific example of how this could be useful. With the Cypress end-to-end testing framework you register global commands with Cypress.Commands.add('myCommand', myCommandFunction) and you use them later by calling cy.myCommand(). The cy global implements the Cypress.Chainable interface so you need to declare your extra utils on this interface.

So let's say you have a commands.ts where you declare your functions:

export function myCommand1(selector: string) {
  // ...
}

export function myCommand2(index: number) {
  // ...
}

Then in an index.d.ts the cleanest thing you can do is:

declare namespace Cypress {
  import * as commands from './commands';
  interface Chainable {
    myCommand1: typeof commands.myCommand1;
    myCommand2: typeof commands.myCommand2;
  }
}

But it would be nice if I could just add all my functions implicitly at once:

declare namespace Cypress {
  import * as commands from './commands';
  interface Chainable {
    ...(typeof commands)
  }
}

That way I can register new commands by just adding a new exported function to commands.ts without having to remember to update another file for typing.

@tilgovi

This comment was marked as outdated.

@GioAc96
Copy link

GioAc96 commented Mar 21, 2023

@istarkov's solution is incomplete. Take the following example:

type IntersectPropToNever<A, B> = {
    [a in keyof (A & B)]: (A & B)[a]    
}
type Spread<A, B> = IntersectPropToNever<A, B> & A | B;

type Test = Spread<{
    a: true
}, {
    a?: false
}>

Test['a'] should evaluate to boolean, instead it evaluates to boolean | undefined. I advice people to stick to @kentcdodds's answer

@GioAc96
Copy link

GioAc96 commented Mar 21, 2023

Thanks @sandersn! I've also found type-fest which has a Merge utility which does a good job of this as well.

I think you mean Spread,Merge does not correctly evaluate spreads.

@qwertie
Copy link

qwertie commented Jan 10, 2024

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.

  • { ...spread } becomes {} ... spread.
  • { a, b, c, ...d} becomes {a, b, c} ... d

Um... I guess everyone else understands this given there are hundreds of upvotes and I'm the only 😕, but why does one need to "convert the syntax to binary syntax"? The obvious syntax is like

type A = { a:number, b?: number };
type B = { b:string };
let ab: { ...A, ...B } = { ...{ a: 1, b: 2 }, ...{ b: 'hi' } };

I was imagining that maybe binary ... represented a "compute difference" operator perhaps, but then rest(A, P) was introduced to do a very similar thing (though in a confusing way that is syntactically unlike anything else in TypeScript or JavaScript) so I'm still confused 😕.

@d07RiV
Copy link

d07RiV commented Jul 30, 2024

Since this has been marked as duplicate of #11100 I have to comment here, even though it's unrelated to spread operator.

What is the reason for Object.assign(foo, bar) allowing properties of foo to be overridden with different types? I think in any situation where the first argument is not a literal, it should treat the remaining arguments as partial of it's type?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Revisit An issue worth coming back to Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests