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

Proposal: Strong typed object assigning #7437

Closed
shadeglare opened this issue Mar 8, 2016 · 14 comments
Closed

Proposal: Strong typed object assigning #7437

shadeglare opened this issue Mar 8, 2016 · 14 comments
Labels
Question An issue which isn't directly actionable in code

Comments

@shadeglare
Copy link

This proposal is mostly influenced by F# object expression syntax.

There's no ability in TypeScript to create a new object based on another one with new property values and a full type checking.

ES6 introduced the Object.assign function but it has a design flaw that allows a developer to ignore an object interface shape and even break it when the interface changes.

There could be a special syntax for the object assigning I have a naive proposal for.
Let there be a special keyword assign. Below the fragments show how it could work.

Simple example with a plain interface:

interface Point {
    x: number
    y: number
}
function toPoint(x: number, y: number): Point {
    return { x, y }
}

let p1 = toPoint(1, 2) //p1 is { x: 1, y: 2 }
let p2 = p1 assign { x: 5 } //p2 is { x: 5, y: 2 }
let p3 = p1 assign { y: 7 } //p3 is { x: 1, y: 2 }
let p4 = p1 assign { z: 5 } //Error: There's no z prop in the Point interface

More complex example with nesting:

interface User {
    name: string
    address: {
        street: string
        house: number
    }
}
function toUser(name: string, street: string, house: number): User {
    return  { name, address: { street, house } }
}

let u1 = toUser("name1", "street1", 42)
// create a new user object with a new name
// u2 will be:
// {
//   name: "name2",
//   address: { street: "street1", house: 42 }
// }
let u2 = u1 assign { name: "name2" }
// create a new user object with a new house number
// u3 will be:
// {
//   name: "name1",
//   address: { street: "street1", house: 13 }
// }
//A typical transpile scenario is
//var u3 = Object.assign(u1, { address: Object.assign(u1.address, { house: 13 }) })
let u3 = u1 assign { address: { house: 13 } }
// u3 creation can be rewritten in a more verbose nested way
let u3 = u1 assign { address: u1.address assign { house: 13 } }

The right side object interface of assigning must be always checked against the left side object interface. If the right side interface partially(or fully) matches the left side interface then an assigning expression is valid otherwise not.

@Arnavion
Copy link
Contributor

Arnavion commented Mar 8, 2016

Perhaps you're looking for https://github.com/sebmarkbage/ecmascript-rest-spread

let p2 = { ...p1, x: 5 }; //p2 is { x: 5, y: 2 }

@shadeglare
Copy link
Author

@Arnavion It's a nice feature but there's no type checking in this expression. I'd like to have the full type checking when the x property is renamed. There's a type check between left and right sides of assigning in my proposal.

@Arnavion
Copy link
Contributor

Arnavion commented Mar 8, 2016

My point was that if that feature was in ES, then the TS support for it would include type-checking.

Edit: Existing issue is #2103

@jeffreymorlan
Copy link
Contributor

There's no ability in TypeScript to create a new object based on another one with new property values and a full type checking.

There's always the = operator:

// Returns shallow copy of a plain object.
function clone<T>(obj: T): T {
    var newObj: any = {};
    for (var k in obj) newObj[k] = (<any>obj)[k];
    return newObj;
}

let p1 = toPoint(1, 2); // p1 is { x: 1, y: 2 }
let p2 = clone(p1); p2.x = 5; // p2 is { x: 5, y: 2 }
let p3 = clone(p1); p3.y = 7; // p3 is { x: 1, y: 7 }
let p4 = clone(p1); p4.z = 5; // Error: Property 'z' does not exist on type 'Point'.

@ThomasMichon
Copy link
Member

You actually can define a type-safe signature for the assign function using intersection types. You just have to do it in a roundabout way.

function assign<B, E>(base: B & E, extension: E): B & E {
    // return Object.assign(base, extension);
    for (let key in extension) {
        base[key] = extension[key];
    }

    return base;
}

interface IA {
    a: number;
    b: number;
    c: number;
}

let a: IA = {
    a: 1,
    b: 2,
    c: 3
};

assign(a, {
    b: 2,
    c: 3
}); // Compiles.

assign(a, {
    d: 4
}); // Fails to compile.

assign(a, {
    c: 'test'
}); // Fails to compile.

The magic trick is that TypeScript is actually checking the type of your base object, not the extension.

This signature should at least enforce that all extension fields exist on your base object across your project.The bad news is that it will not automatically rename fields in the right places, but it will at least identify the places impacted by a rename.

@shadeglare
Copy link
Author

@ThomasMichon Looks nice but the assign function returns not the same type result as expected. Seems like a bug.

Here's an example:

function copy<T>(value: T): T {
    let result = {}
    for (let prop in value) result[prop] = value[prop]
    return <T>result
}

function assign<B, E>(target: B & E, ...source: E[]): B & E {
    let result = copy(target)
    source.forEach(x => {
        for (let prop in x) result[prop] = x[prop]
    })
    return result
}

interface User {
    name: string
    age: number
    address: {
        house: number
        street: string
    }
}

function toUser(name: string, age: number, street: string, house: number): User {
    return {
        name, age, address: { house, street }   
    }
}
function printUser(user: User) {
    console.log(user)
}

let u1 = toUser("u1", 18, "s1", 1)
let u2 = assign(u1, { name: "u2" })
printUser(u2) //error, the type  {} & { name: string }, expected User

@ThomasMichon
Copy link
Member

Thanks to TypeScript 1.8, here's a fix:

function assign<B, E, T extends B & E>(base: T, extension: E): T {
    for (let key in extension) {
        base[key] = extension[key];
    }

    return base;
}

This enforces that return type matches that of the input, but that E combined with some middle-man type must produce T.

@shadeglare
Copy link
Author

@ThomasMichon Oh. That's cool.

@mhegazy
Copy link
Contributor

mhegazy commented Mar 10, 2016

@ThomasMichon's proposal does address the original issue. the behavior of Object.assign, as spec'ed, is different from this though, it allows you to extend the object. so using a non-existing type operator extends:

assign<T, S1, S2>(target: T, source1: S1, source2: S2): S2 extends S1 extends T;

@mhegazy mhegazy closed this as completed Mar 10, 2016
@mhegazy mhegazy added the Question An issue which isn't directly actionable in code label Mar 10, 2016
@zamb3zi
Copy link

zamb3zi commented Apr 27, 2016

All of the definitions of assign suggested so far appear to suffer from the following problem:

interface IFoo {
    data: number | string;
}
const a: IFoo = {data: 5};
assign(a, {data: 6});

Generating:

error TS2345: Argument of type 'IFoo' is not assignable to parameter of type '{} & { data: number; }'.
  Type 'IFoo' is not assignable to type '{ data: number; }'.
    Types of property 'data' are incompatible.
      Type 'number | string' is not assignable to type 'number'.
        Type 'string' is not assignable to type 'number'.

The problem disappears if the | string is removed.

@codeepic
Copy link

codeepic commented Jul 5, 2016

@ThomasMichon The assign function still doesn't work, I am using TypeScript 1.8.10.
Error: Assigned expression type IRequest is not assignable to type IBenchmarkRequest.

@AlexGalays
Copy link

AlexGalays commented Aug 25, 2016

This is a very common usage in 2015/2016 where people are now trying to do immutable object/array updates on top of JSON-like mutable data structures. typescript is useful at typing data accesses, but so far can't handle immutable JSON updates at all.
Since there are no typesafe workarounds, I am hoping for a language update that will address this!
Cheers

@ThomasMichon 's snippet only works with very simple examples. It fails with type unions, etc.

@mindplay-dk
Copy link

This seems to be getting increasingly relevant now in 2017.

@AlexGalays
Copy link

Forgot about this thread!
I released a library to do type-safe, deep immutable updates on trees of Arrays/Objects:

https://github.com/AlexGalays/immupdate#update-nested-property

Hope it helps.

@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
Question An issue which isn't directly actionable in code
Projects
None yet
Development

No branches or pull requests

9 participants