Skip to content

Make defining a data class easier #38442

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

Open
5 tasks done
zen0wu opened this issue May 9, 2020 · 9 comments
Open
5 tasks done

Make defining a data class easier #38442

zen0wu opened this issue May 9, 2020 · 9 comments
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@zen0wu
Copy link

zen0wu commented May 9, 2020

Search Terms

Data class, Property parameters

Suggestion

Data class just means a class, with certain forms of pattern of how it is defined (required), and some predefined methods (optional, like hashCode, equals...).

Basically we want to achieve the following, very simple goal.

const data = new SomeData({ field1: 'abc', field2: 123 })  // But think there're 10 fields
data.field1 // This should have proper jsdoc showing when hovering

Goals:

  1. Use keyword arguments to instantiate the class.
    • This is because for larger class, fields can easily have the same type and using an array-like argument is an anti-pattern
  2. Very easy to define the class itself, without a tons of boilerplate
  3. (Optional) Being able to define a DataClass generic class
  4. (Optional) Play nice with property visibility modifier (private/protected/public)
  5. (Optional) Can deal with default value

The most immediate pain point is, due to lack of keyword arguments in JS, defining a data class is very awkward. There are a few ways to mimic a data class, but they all come with different awkwardness.

The most correct and cumbersome definition

class SomeData {
  field1: string
  field2: number

  constructor(args: { field1: string, field2: number }) {
    this.field1 = args.field1
    this.field2 = args.field2
  }
}

That's a lot of boilerplate! Each field is written 4 times! This already feels like Java.

Property parameters

class SomeData {
  constructor(private field1: string, private field2: number) {}
}

Most ideal when defining the class, but don't really satisfy requirement 1.

Define constructor argument based on class info

class SomeData {
  private field1!: string
  private field2!: number  // The '!', ehhh

  constructor(args: Partial<SomeData>) {
    Object.assign(this, args)
  }
}

The issues with this approach is:

  • Under strict, have to add ! to every field
  • Partial cannot ensure the most correct type info

Another try to improve the type safety around missing fields:

type Shape<T> = { [P in keyof T]: T[P] }
class SomeData {
  private field1!: string
  private field2!: number  // The '!', ehhh

  constructor(args: Shape<SomeData>) {
    Object.assign(this, args)
  }
}

The issue is, Shape will include all the methods and stuff that's not really part of the constructor we want.

Interface + Class

interface SomeDataProps {
  field1: string
  field2: number
}

class SomeData implements SomeDataProps {
  field1!: string // Have to repeat them
  field2!: number
  
  constructor(args: SomeDataProps) {
    Object.assign(this, args)
  }
}

Use Cases

Data class is a super common use case, and is already natively supported in other languages like in Kotlin, Python, Scala and etc.

That being said, I wouldn't doubt this would have numerous use cases and it would make things so much easier, especially in large code base.

Use case I've personally seen:

Thinking about React's component and their props, it's actually the same pattern, compared to the interface+class approach above. But React's choice is to have to append .props on every field access. Reasonable choice given there's also this.state, but it could be better.

Possible Solutions

There's many ways we could make this better. Just listing some of the possibility. I'm honestly not sure which one is the best. But the idea here is to have a design that have no runtime assumption or changing how JS is emitted.

Non-solutions:

  1. data class (as in kotlin/scala) modifier on class definition. This would have runtime implications, changing how classes code are emitted in JS
  2. decorator (as in python): Don't think it's possible, since currently TS's decorator has no ability to change the class's typing info

Extend property parameters to support objects

class SomeData {
  constructor(args: {
    private field1: number
    private field2: string
  }) { }
}

Looks legit and scoped, since. this only changes the typing of constructor.
But this might put limitation on constructor arguments: For example, maybe we only allow one constructor argument to have this behavior, or not.

Have a way to mark fields as needed in constructor

Similiarly to readonly:

class SomeData {
  construct field1: number = 10  // Can have default value
  construct field2: string

  // Alternatively, this constructor can be auto generated, but that's really against the design goal
  constructor(args: construct SomeData) {
    // construct SomeData = { field2: string; field1?: number }
    // Maybe if there's a way to query a object type with whether each field has the construct modifier, it could be something like
    //   type Construct<T> = { [P in keyof T]: construct T[P] ? T[P] : never }
    // Ideally, we could figure out the intersection of args and the fields with default values forms correctly SomeData
    Object.assign(this, args)
  }
}

// Maybe this could be generalized by using "args: construct this"?

Asserts in constructor

This has to use the Props pattern. Given this is not really adding return types to constructor, but merely an assert type. Definitely feels harder to implement because it might interfere with flow control analysis. But the nice thing is,

  • constructor don't have return type either way, so that complies with assert methods don't have a return type.
  • assert methods currently have the limitation to have to explicitly mark the type of the receiver, where in this case, we don't have to, since it's a constructor, we already know what it is.
interface SomeDataProps {
  field1: number
  field2: string
}

class SomeData {
  constructor(props: SomeDataProps): asserts this is SomeDataProps {
     Object.assign(this, props)
  }
}

// And, it's possible to have a generic one!
class DataClass<Props> {
  constructor(props: Props): asserts this is Props {
    Object.assign(this, props)
  }

  // hashCode, equals...
}

class SomeData extends DataClass<SomeDataProps> {
  // methods
}

Checklist

I'm quite sure we can find a way to satisfy all the guidelines here.

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.
@dragomirtitian
Copy link
Contributor

The most concise way I found to define such a class, is to use a function to generate a class. In the function the class uses Object.assign to assign the values of the object to this. When the class is returned it is asserted that the returned class has all the properies defined.

function autoImplements<T>() {
    return class {
        constructor(data: T) {
            Object.assign(this, data)
        }
    } as new (data: T) => T
}

interface Data {
  prop : number;
}

class SomeData extends autoImplements<Data>() {
    m() {
        this.prop
    }
}

let s = new SomeData({
    prop: 2
})

s.prop

Playground Link

@zen0wu
Copy link
Author

zen0wu commented May 10, 2020

@dragomirtitian This is actually quite good! The only thing it doesn't deal with is the visibility modifier, default value, and not being able to use decorator.

It's a great start. If this issue didn't end up go anywhere, I'll definitely use this approach.

@dragomirtitian
Copy link
Contributor

@zen0wu you can add a defaults parameter to the function. With regard to visibility, I'm not sure exactly what you would want to do, but you can't make just some of the properties public. And decorators could be an issue. But you can probably just redefine the fields you want decorated

@zen0wu
Copy link
Author

zen0wu commented May 11, 2020

@dragomirtitian re default parameters - I guess we could, with a second generic denoting a subset of T, something like Playground Link. Some rough edges like overwriting default values is a bit weird, probably solvable.

By visibility, compared to parameter properties, we can freely define the visibility of each individual parameters.

For decorators, this is mostly from the typeorm usecase, so basically every field will be decorated :)

@dragomirtitian
Copy link
Contributor

@zen0wu I still think this would be a bit confusing, I think something like #7061 would actually make this use case easier as well, without introducing confusion.

@RyanCavanaugh RyanCavanaugh added Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript labels May 11, 2020
@bogdanionitabp
Copy link

named parameters to the constructor (like in Python) would solve this very elegantly

@zen0wu
Copy link
Author

zen0wu commented May 31, 2020

@bogdanionitabp could you elaborate? Are you proposing a change or there’s already a solution in your mind?

@arogg
Copy link

arogg commented Jun 27, 2020

@bogdanionitabp there should only be 1 parameter to the constructor. otherwise you would always have to rewrite all the parameters for the super call. also you might want to define the shape of the parameters somewhere in a separate object. can't be done if you have multiple parameters. C# does a lot of thing better then JS, but named params (C# has them) are a bit of a pain point.

@raythurnvoid
Copy link

raythurnvoid commented Feb 1, 2024

#38442 (comment) @dragomirtitian Nice one, this is a little bit better, it avoids to create a new class every time:

const Struct = class Struct {
	constructor(data: any) {
		Object.assign(this, data);
	}
} as new <T>(data: T) => T;

class Hello extends Struct<{
	x: string;
}> {}

const hello = new Hello({ x: "world" });

console.log(hello.x); // world

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

7 participants
@zen0wu @RyanCavanaugh @arogg @dragomirtitian @raythurnvoid @bogdanionitabp and others