Repository: alexeyraspopov/dataclass Branch: master Commit: 754b8b3cf9da Files: 37 Total size: 75.0 KB Directory structure: gitextract_5xq9i1un/ ├── .github/ │ └── workflows/ │ ├── docs.yaml │ └── testing.yaml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs/ │ ├── .vitepress/ │ │ ├── .gitignore │ │ └── config.js │ ├── guide/ │ │ ├── caveats.md │ │ ├── contributing.md │ │ ├── getting-started.md │ │ ├── index.md │ │ ├── installation.md │ │ ├── migrating.md │ │ ├── objects-equality.md │ │ └── serialization-deserialization.md │ ├── index.md │ ├── package.json │ └── reference/ │ └── index.md ├── integration/ │ ├── Data.test.ts │ ├── integration.js │ ├── legacy.json │ ├── modern.json │ ├── runtime.test.ts │ └── spec.json ├── modules/ │ ├── Data.js │ └── runtime.js ├── package.json ├── rollup.config.mjs ├── tsconfig.json ├── typings/ │ ├── dataclass.d.ts │ ├── dataclass.js.flow │ ├── runtime.d.ts │ └── runtime.js.flow └── vitest.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/docs.yaml ================================================ name: Docs on: push: branches: [master] workflow_dispatch: jobs: deploy: name: Deploy Docs runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 with: node-version: 18 - name: Install docs-related dependencies run: cd docs && npm install && npm run build && cd .. - name: Deploy to GitHub Pages uses: crazy-max/ghaction-github-pages@v2 with: target_branch: gh-pages build_dir: docs/.vitepress/dist fqdn: dataclass.js.org verbose: true env: GITHUB_TOKEN: ${{ secrets.DOCS_GITHUB_TOKEN }} ================================================ FILE: .github/workflows/testing.yaml ================================================ name: Testing on: push: branches: [master] pull_request: branches: [master] jobs: integration: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 with: node-version: 20 - run: npm install - run: npm run build - run: TARGET=modern npm run integration - run: TARGET=spec npm run integration - run: TARGET=legacy npm run integration - run: npm run test ================================================ FILE: .gitignore ================================================ node_modules coverage build ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## [`v2.1.1`](https://github.com/alexeyraspopov/dataclass/releases/tag/v2.1.1) - TypeScript typings fix: omit `Data` base class keys in `create()` and `copy()` signatures. Not a breaking change since attempt to override these keys would lead to runtime error already. Mainly affects autocomplete function of your editor, only showing the keys that can be updated. ## [`v2.1.0`](https://github.com/alexeyraspopov/dataclass/releases/tag/v2.1.0) - Data instances are now sealed. Adding extra keys via `create()` or `copy()` will result in runtime error. If type system is properly utilized, this should not create any issues to existing code. - Fully rewritten instantiation and copy algorithms with backward compatibility. New implementation consumes less memory and uses faster approach in copying objects. - Fixed dynamic defaults being re-generated after `copy()` - `copy()` methods now both can omit the argument, creating a referential copy of the instance. - `equals()` now compares all keys (previously it was checking only the ones overriding defaults). The assumed optimizaiton in time didn't pay out and only caused unnecessary complication to copying mechanism and higher memory consumtion. ## [`v2.0.0`](https://github.com/alexeyraspopov/dataclass/releases/tag/v2.0.0) - Dataclass is now licensed under [ISC License](https://en.wikipedia.org/wiki/ISC_license) https://github.com/alexeyraspopov/dataclass/blob/master/LICENSE - **Breaking:** the utility class has been renamed from `Record` to `Data` - "Record" now means a lot of other things in the ecosystem - **Breaking:** use `.create()` static method instead of `new` operator - This fixes the issue with existing browser implementation of class properties - The use of `new` operator now throws a runtime error - **Breaking:** TypeScript classes no longer need to be generic - **Breaking:** an attempt to mutate properties now throws runtime errors - **Breaking:** use named import instead of default `import { Data } from "dataclass"` - This should fix possible CJS/ESM compatibility issues and allow future API extensions - **Breaking:** explicit `toJSON()` implementation has been removed, _but the behavior is preserved_ - **Breaking:** library code is no longer transpiled to ES5 - Unless you support evergreen browsers, you still need to transpile TypeScript or class properties so the build step is inevitable. Thus, make sure `dataclass` is transpiled if necessary - Fixed `equals()` algorithm to ensure proper custom values comparison - Fixed `equals()` algorithm to avoid runtime errors for nullable properties - Added `sideEffects: false` to `package.json` ## [`v1.2.0`](https://github.com/alexeyraspopov/dataclass/releases/tag/v1.2.0) - Fixed the ability to subclass records - Added support for `toJSON()` of nested records - Ensure `equals()` works for nested records - Use `valueOf()` as a part of `equals()` algorithm to support complex structures ## [`v1.1.0`](https://github.com/alexeyraspopov/dataclass/releases/tag/v1.1.0) - Implement default `toJSON()` serialization behavior ## [`v1.0.4`](https://github.com/alexeyraspopov/dataclass/releases/tag/v1.0.4) - Added support for custom getters ## [`v1.0.3`](https://github.com/alexeyraspopov/dataclass/releases/tag/v1.0.3) - Fixed Flow typings ## [`v1.0.2`](https://github.com/alexeyraspopov/dataclass/releases/tag/v1.0.2) - Fixed `copy()` method for TypeScript ## [`v1.0.0`](https://github.com/alexeyraspopov/dataclass/releases/tag/v1.0.0) - Initial public version ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: - Using welcoming and inclusive language - Being respectful of differing viewpoints and experiences - Gracefully accepting constructive criticism - Focusing on what is best for the community - Showing empathy towards other community members Examples of unacceptable behavior by participants include: - The use of sexualized language or imagery and unwelcome sexual attention or advances - Trolling, insulting/derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or electronic address, without explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at oleksii.raspopov@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing Guide Greetings! I'm glad that you are interested in contributing to this project. Before submitting your contribution though, please take a moment and read through the following guidelines. ## Issue Reporting Guidelines - You are free to open an issue with any question you have. This helps us to improve the docs and make the project more developers-friendly. - Make sure you question has not been answered before in other issues or in the docs. - Please provide an environment or list of steps to reproduce the bug you've found. You can attach a link to a repo or gist that has all the sources needed for reproducing. ## Pull Request Guidelines - Feel free to open pull requests against `master` branch. - Provide descriptive explanation of the things you want to fix, improve, or change. - Create new automated tests for bug fixes, to ensure the effect of introduced changes and ability to avoid regressions. - Keep git history clear and readable. No "ugh linter again" commits. ================================================ FILE: LICENSE ================================================ ISC License Copyright (c) 2017-2024 Oleksii Raspopov Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ================================================ FILE: README.md ================================================ # dataclass npm install dataclass Syntax sugar that leverages the power of available type systems in TypeScript and JavaScript to provide an effortless way for defining value objects that are immutable and persistent. Read full docs [on the website](https://dataclass.js.org). ```ts import { Data } from "dataclass"; class User extends Data { name: string = "Anon"; age: number = 25; } let user = User.create({ name: "Liza", age: 23 }); // > User { name: "Liza", age: 23 } let updated = user.copy({ name: "Ann" }); // > User { name: "Ann", age: 23 } let isEqual = user.equals(updated); // > false ``` ## Prior Art The implemented concept is heavily inspired by Scala and Kotlin. Both languages have the implementation of data classes as a part of their syntax and share similar APIs. See [Data Classes](https://kotlinlang.org/docs/reference/data-classes.html) in Kotlin (also [Case Classes](https://docs.scala-lang.org/tour/case-classes.html) in Scala): ```kotlin data class User(val name: String = "Anonymous", val age: Int = 0) val user = User(name = "Liza", age = 23) val updated = user.copy(name = "Ann") user.equals(updated) ``` And [Data Classes](https://docs.python.org/3/library/dataclasses.html) in Python: ```python from dataclasses import dataclass, replace @dataclass class User: name: str = "Anonymous" age: int = 0 user = User(name="Liza", age=23) updated = replace(user, name="Ann") user == updated ``` ## Contributing The project is opened for any contributions (features, updates, fixes, etc). If you're interested, please check [the contributing guidelines](https://github.com/alexeyraspopov/dataclass/blob/master/CONTRIBUTING.md). The project is licensed under the [ISC](https://github.com/alexeyraspopov/dataclass/blob/master/LICENSE) license. ================================================ FILE: docs/.vitepress/.gitignore ================================================ cache dist ================================================ FILE: docs/.vitepress/config.js ================================================ export default { title: "dataclass", description: "Data Classes for TypeScript & JavaScript", lastUpdated: true, themeConfig: { nav: [ { text: "Guide", link: "/guide/" }, { text: "Reference", link: "/reference/" }, ], sidebar: [ { text: "Guide", items: [ { text: "Introduction", link: "/guide/" }, { text: "Installation", link: "/guide/installation" }, { text: "Getting Started", link: "/guide/getting-started" }, { text: "Objects Equality", link: "/guide/objects-equality" }, { text: "Serialization & Deserialization", link: "/guide/serialization-deserialization" }, { text: "Caveats", link: "/guide/caveats" }, { text: "Migrating", link: "/guide/migrating" }, { text: "Contributing", link: "/guide/contributing" }, ], }, { text: "Reference", items: [{ text: "API Reference", link: "/reference/index" }], }, ], outline: "deep", search: { provider: "local", }, editLink: { pattern: "https://github.com/alexeyraspopov/dataclass/edit/master/docs/:path", }, socialLinks: [{ icon: "github", link: "https://github.com/alexeyraspopov/dataclass" }], externalLinkIcon: true, footer: { message: `Made by Oleksii Raspopov with ❤️`, copyright: "ISC License © Oleksii Raspopov", }, }, }; ================================================ FILE: docs/guide/caveats.md ================================================ # Caveats In the world of always changing tooling and evolving specs, it is hard to avoid weird edge cases that can lead to hours of wasted time. On this page you may find some known caveats and potential issues when using data classes, and possible ways to resolve or avoid them. ## Optional keys and code compilation Defining a data class you may find yourself in a situation where a property doesn't have a reasonable default value. Actual value may be provided during instantiation or the property's value can be treated as missing. The most universal approach (i.e. works in any environment for both TypeScript and Flowtype) going to be the use of `null` as default value: ```ts class Entity extends Data { optionalProp: string | null = null; } let entity = Entity.create(); // > Entity { optionalProp: null } let payload = JSON.stringify(entity); // > { "optionalProp": null } ``` When using TypeScript, you may want to use optional parameters instead of nullable types, as a less noisy syntax: ```ts class Entity extends Data { optionalProp?: string; } let entity = Entity.create(); // > Entity { optionalProp: undefined } ``` When doing so, you have to consider ensure following: you're using `tsc` version 4.3.2 or higher and either your compile `target` is `es2022` or [`useDefineForClassFields` is set to `true`](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#the-usedefineforclassfields-flag-and-the-declare-property-modifier). You may find this flag being set to `false` if some dependencies in your system rely on the old behavior of class fields initialization. In any other case, optional properties are going to be missing completely and you won't be able to define them using `create()` method. **Warning**: During serialization, undefined values do not appear in resulting JSON string: ```ts{8-9} class Entity extends Data { optionalProp?: string; } let entity = Entity.create(); // > Entity { optionalProp: undefined } let payload = JSON.stringify(entity); // > {} ``` When using Flowtype, it is preferable to stick to nullable types with `null` explicitly set as default value. Basic Flowtype plugins for Babel going to strip out typings including properties themselves. ```js class Entity extends Data { optionalProp: ?string = null; } let entity = Entity.create(); // > Entity { optionalProp: null } let payload = JSON.stringify(entity); // > { "optionalProp": null } ``` ## Explicit `this` signature in methods When using TypeScript, whenever a data class contains a method, that uses `this.copy()` or `this.equals()`, you may see a typing error. But you have done nothing wrong. There are some limitations in TypeScript or this library's typings, but that's something you can easily fix: ```ts{4-5} class Entity extends Data { counter: number = 13; // set explicit this type to a method that uses other data class methods increment(this: Entity) { return this.copy({ counter: this.counter + 1 }) } } let entity = Entity.create({ counter: 10 }); // > Entity { counter: 10 } let updated = entity.increment(); // > Entity { counter: 11 } ``` ================================================ FILE: docs/guide/contributing.md ================================================ # Contributing The project is opened for any contributions (features, updates, fixes, etc) and is [located](https://github.com/alexeyraspopov/dataclass) on GitHub. If you're interested, please check [the contributing guidelines](https://github.com/alexeyraspopov/dataclass/blob/master/CONTRIBUTING.md). The project is licensed under the [ISC](https://github.com/alexeyraspopov/dataclass/blob/master/LICENSE) license. ================================================ FILE: docs/guide/getting-started.md ================================================ # Getting Started ## Defining data classes This library provides an abstract class `Data`: ```ts:no-line-numbers import { Data } from "dataclass"; ``` Which allows to define custom data classes with their set of fields. Assuming, the user is aware of type systems and have one enabled for their project, this library does not do any type checks in runtime. This means less overhead for the things, that have to be preserved in compile time or by a safety net of tests. The peak of developer experience can be achieved by using TypeScript or JavaScript that is extended by [class properties](https://github.com/tc39/proposal-class-fields) and [flowtype](https://flow.org). This allows to write a class with a set of fields following by their types and default values: ```ts class User extends Data { name: string = "Anonymous"; age: number = 0; } ``` Providing a set of fields defines the class' API. ## Creating data objects and accessing properties New entity is created by using static method `create()` provided by `Data`: ```ts let userWithCustomValues = User.create({ name: "Liza", age: 23 }); // > User { name: "Liza", age: 23 } let userWithDefaultValue = User.create({ name: "Ann" }); // > User { name: "Ann", age: 0 } ``` **Warning**: the ability to use `new` operator is prohibited since `Data` needs access to all properties. Created entity has all the fields' getters that return either custom or default value: ```ts // custom value provided to constructor userWithCustomValues.name === "Liza"; // default value used from the model definition userWithDefaultValue.age === 0; ``` ## Making changes in data objects Whenever a change should be made, there is `copy()` method that has the same signature as constructor, based on a fields definition: ```ts let user = User.create({ name: "Ann" }); // > User { name: "Ann", age: 0 } let updated = user.copy({ age: 28 }); // > User { name: "Ann", age: 28 } ``` This method returns a new entity built upon previous set of values. The target of `copy()` calls is not changed, by the definition of persistence. ## Comparing data objects by value Since all the entities of one class are unique by their object reference, comparison operator will always give `false` as a result. To compare the actual properties of the same class' entities, `equals()` method should be used: ```ts let userA = User.create({ name: "Ann" }); let userB = User.create({ name: "Ann" }); userA === userB; // > false userA.equals(userB); // > true ``` All the API is fully compatible, so the code looks the same in JavaScript and TypeScript. ## Going beyond properties Often, models may have a set of additional getters that represent computed values based on raw data. They can be easily described as plain class' methods: ```ts{6-8,10-12} class User extends Data { firstName: string = "John"; lastName: string = "Doe"; age: number = 0; isAdult() { return this.age >= 18; } getFullName() { return `${this.firstName} ${this.lastName}`; } } ``` Getters may receive arguments, however it is recommended to keep them primitive, so a model [won't know](https://en.wikipedia.org/wiki/Law_of_Demeter) about some others' internals. ## Nested data objects When you're modeling complex domains, you may find the need to have one value object as a part of another value object. This library supports it seamlessly: ```ts{7} class Url extends Data { protocol: string = "https"; hostname: string; } class Server extends Data { location: Url; } ``` ## Dynamic values as defaults Default values of data class properties must be useful. JavaScript provides an ability to use any expression as a value of class property, and so `dataclass` allows you to leverage this for good. ```ts import { v4 as uuidv4 } from "uuid"; class Entity extends Data { id: string = uuidv4(); } let entityA = Entity.create(); // > Entity { id: '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' } let entityB = Entity.create(); // > Entity { id: '1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed' } ``` **Note**: make sure to use `dataclass` version 2.1.0 or higher to avoid some old bugs related to dynamic defaults. ================================================ FILE: docs/guide/index.md ================================================ # Introduction npm install dataclass Syntax sugar that leverages the power of available type systems in TypeScript and JavaScript to provide an effortless way for defining value objects that are immutable and persistent. Dataclass can be used in browsers, Node.js, and Deno. ```ts:no-line-numbers{3,9,13,19} import { Data } from "dataclass"; // 1. easily describe your data classes using just language features class User extends Data { name: string = "Anon"; age: number = 0; } // 2. instantiate classes while type systems ensure correctness let user = User.create({ name: "Liza", age: 23 }); // > User { name: "Liza", age: 23 } // 3. make changes while benefiting from immutable values let updated = user.copy({ name: "Ann" }); // > User { name: "Ann", age: 23 } updated = updated.copy({ name: "Liza" }); // > User { name: "Liza", age: 23 } // 4. compare objects by their value, not reference console.log(user === updated, user.equals(updated)); // > false, true ``` ## Quick Start 1. **Define data class** — use language features to define the data schema and default values ```ts:no-line-numbers class Task extends Data { contents: string = ""; completed: boolean = false; priority: number = 1; } ``` 2. **Create data objects** — create immutable instance with custom values, fallback to defaults if necessary ```ts:no-line-numbers let taskA = Task.create({ contents: "Upgrade dependencies", }); // > Task { contents: "Upgrade dependencies", completed: false, priority: 1 } let taskB = Task.create({ contents: "Provide work summary", priority: 2, }); // > Task { contents: "Provide work summary", completed: false, priority: 2 } ``` 3. **Read values** — data objects behave just like any other objects, read the properties you defined before ```ts:no-line-numbers let tasks: Array = [ /* some Task data objects */ ]; tasks.sort((a, b) => (a.priority > b.priority ? -1 : 1)); ``` 4. **Make copies** — whenever a change is needed, make a copy data object with the new values ```ts:no-line-numbers function markCompleted(task: Task) { return task.copy({ completed: true }); } ``` 5. **Compare by values** — compare data objects by their value instead of immutable reference ```ts:no-line-numbers function isTicketChanged(prev: Task, next: Task) { return !prev.equals(next); } ``` 6. Read more in [Getting Started](./getting-started.md) guide, or see deep dive explanation of different aspects: [value object equality](./objects-equality.md), [serialization/deserialization](./serialization-deserialization.md). ## Prior Art The implemented concept is heavily inspired by Scala and Kotlin. Both languages have the implementation of data classes as a part of their syntax and share similar APIs. See [Data Classes](https://kotlinlang.org/docs/reference/data-classes.html) in Kotlin (also [Case Classes](https://docs.scala-lang.org/tour/case-classes.html) in Scala): ```kotlin:no-line-numbers data class User(val name: String = "Anonymous", val age: Int = 0) val user = User(name = "Liza", age = 23) val updated = user.copy(name = "Ann") user.equals(updated) ``` And [Data Classes](https://docs.python.org/3/library/dataclasses.html) in Python: ```python:no-line-numbers from dataclasses import dataclass, replace @dataclass class User: name: str = "Anonymous" age: int = 0 user = User(name="Liza", age=23) updated = replace(user, name="Ann") user == updated ``` ================================================ FILE: docs/guide/installation.md ================================================ # Installation ### Installing via NPM The library is available [in NPM registry](https://www.npmjs.com/package/dataclass) and can be installed via NPM or similar package manager: ```sh:no-line-numbers npm install dataclass ``` ### Installing via CDNs The library can be imported via [UNPKG](https://unpkg.com/). It is recommended to use `?module` parameter to import ES Module version of the code: ```js:no-line-numbers import { Data } from "https://unpkg.com/dataclass@2?module"; ``` _Note: the library does not support [UMD](https://github.com/umdjs/umd) format._ In similar way, the library can be imported via [esm.sh](http://esm.sh/). This can be useful for [Deno](https://deno.land/) since this CDN also serves `.d.ts` files. ```ts:no-line-numbers import { Data } from "https://esm.sh/dataclass@2"; ``` _Note: it is preferable to put explicit version range in the URL._ ## Troubleshooting The library is shipped with CommonJS and ES Module support. The source code is written using ES2015 features. Given [the global reach](https://caniuse.com/es6-class) of ES2015 Classes it is very likely you won't need to compile this type of things. If the environments you are targetting support these features or you know for sure that a node module will be properly pre-compiled if necessary, you can skip the rest of this guide. If older standards support required, the bundler of choice needs to be configured to transpile `dataclass` dependency as well. Assuming you would like to use `dataclass` for its typings benefits, you already have the build step in your environment. ### Using with Parcel or Vite Parcel is capable of properly transpiling `node_modules` and relies on Browserslist to figure stuff. Make sure you have `browserslist` defined. Read more [in Parcel docs](https://parceljs.org/getting-started/webapp/#declaring-browser-targets). Vite has a special way to handle dependencies transpiling. Read more [in the related guide](https://vitejs.dev/guide/dep-pre-bundling.html). ### Using with Create React App Create React App transpiles `node_modules` as a part of the build pipeline and relies on Browserslist to figure what to transpile. Make sure you have `browserslist` properly configured. Read more [in CRA docs](https://create-react-app.dev/docs/supported-browsers-features/#configuring-supported-browsers). ### Using with Webpack & Babel It is very likely, that your webpack config excludes `node_modules` from running through `babel-loader` for the sake of faster builds. If any of your targeted environments require code transpiling to ES5 (e.g. Internet Explorer 11), the config will require some changes to make it work with `dataclass` (and possibly other dependencies that are published as a modern JS code). #### Explicit targets including approach The easiest way to extend your existing webpack config to transpile certain node_modules using `babel-loader` is to explicitly mention them in `include` property corresponding rule. _Note: this change can be applied to production config only._ #### Basic dependencies transpiling approach While the previous approach easily works for `dataclass` and in no way affects the build time, there is another approach you may consider, that will potentially help you with other dependecies. _Note: this change can be applied to production config only._ ================================================ FILE: docs/guide/migrating.md ================================================ # Migrating from v1 to v2 ## The class name and import type has been changed The library was created in 2017, long ago before [Records & Tuples proposal](https://github.com/tc39/proposal-record-tuple) was created. The fact this proposal is moving towards being a part of the language means "Record" as a term gains very particular meaning for the ecosystem. Besides, [`Immutable.Record`](https://immutable-js.com/docs/v4.0.0/Record/) and [TypeScript's Record](https://www.typescriptlang.org/docs/handbook/utility-types.html#recordkeys-type) could potentially create confusion as well. Thus, the abstract class `Record` has been renamed to `Data`. Dataclass v1 exposed a single default export which seemed to work just fine for most of the cases. However, it can create additional burden for CommonJS code and require some unnecessary tricks from the bundlers. Thus, Dataclass v2 uses named export. ```diff:no-line-numbers -import Record from "dataclass"; +import { Data } from "dataclass"; ``` ## Drop TypeScript generic from class definitions Dataclass v1 required TypeScript classes to be generic due to [polymorphic `this` for static members issue](https://github.com/Microsoft/TypeScript/issues/5863). The issue has not been resolved but in Dataclass v2 there was a change in typings that helped avoiding the issue in the first place. Now, the user's classes don't need to be generic. ```diff:no-line-numbers -class User extends Record { +class User extends Data { name: string = "Anon"; } ``` ## Use static method `create()` instead of `new` operator Dataclass v2 uses new implementation for class instantiation due to some browser incompatibilities. ```diff:no-line-numbers -let user = new User({ name: "Ann" }); +let user = User.create({ name: "Ann" }); ``` Moving to dataclass v2 will make use of `new` operator throwing runtime errors, suggesting to use static `create()` method instead. ## Ensure no mutations happening in the code While instance of data classes treated as immutable, the implementation still uses some safety precautions to ensure no mutations (accidental or intentional) can be made. In v1, when a prop is mutated, nothing happens, the value remains the same. The operation is basically ignored. ```ts:no-line-numbers{3} let user = new User({ age: 18 }); user.age = 100; console.log(user.age); // > 18 ``` In v2, however, some additional precautions were made, to ensure that developers can spot bad code and mistakes. Mutating a property will now throw an error: ```ts:no-line-numbers{3} let user = new User({ age: 18 }); user.age = 100; // Uncaught TypeError: "age" is read-only ``` This error comes from the use of [`Object.freeze()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze) which throws an error when there was an attempt to mutate an existing property and when the user tries to add new property to the object. ## Make sure the dependency is transpiled, if necessary See [Installation Guide & Troubleshooting](./installation.md#troubleshooting) for more details. ================================================ FILE: docs/guide/objects-equality.md ================================================ # Objects Equality The biggest part of dataclasses flexibility is the fact they can be compared by the value they contain, rather than by the instances reference. Let's consider an example: ```ts import { Data } from "dataclass"; class Circle extends Data { x: number = 0; y: number = 0; radius: number = 0; } let circleA = Circle.create({ x: 0, y: 10, radius: 2 }); // let's assume the new value is coming from an outside source let circleB = circleA.copy({ radius: getCircleRadius() }); // …and now we need to check if the value actually changed let isEqual = circleA.equals(circleB); ``` This guide describes what happens when `target.equals(other)` is being called. 1. The runtime does not check `other` for being the same data class as `target`. This is what supposed to be checked by the typing system (TypeScript or Flowtype) even before the code is executed. 2. The `equals()` method iterates over the properties of the `target` class and compares the values to the same keys in `other` instance. 3. If two values are not strictly equal (via `===` comparison), and both of the values are not nullish (i.e. neither `undefined` nor `null`), the method checks whether these values are data classes that also have `equals()` method. If so, the rest of comparison for these two values is delegated to their `equals()` method. 4. If the values are not data classes, `.valueOf()` method is used for both values to extract possible primitive representation. The resulting values are compared using `===` operator. If result is `false`, `equals()` method returns `false` and skip comparing the rest of the properties. 5. If none of changed properties are different in both `target` and `other`, `equals()` method returns `true`. The idea behind this algorithm attempts to find `equals()` of a dataclass properties is that you can create a data class that will be using another data class as a property. The reason for using `valueOf()` for other types of properties is the fact that there are some data types in JavaScript that are actually value objects and should be compared by their value while having different reference. The prime example of it is `Date`. Instead of directly checking for values to be instanceof of `Date`, `equals()` method relies on the mechanism of `valueOf()` itself, allowing you to define custom `valueOf()` methods for any special data types that can be a part of data classes. ================================================ FILE: docs/guide/serialization-deserialization.md ================================================ # Serialization & Deserialization ## Serialization By default, when using [`JSON.stringify()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify), an instance will be serialized to a plain object with all the fields as is. This works without any additional effort ```ts{5-8} class User extends Data { name: string = "Anonymous"; age: number = 0; // implicit behavior, no need to implement toJSON(): Object { return { name: this.name, age: this.age }; } } let user = User.create({ name: "Liza", age: 23 }); // > User { name: "Liza", age: 23 } JSON.stringify(user); // > { "name": "Liza", "age": 23 } ``` ## Deserialization In cases where the input data cannot be determined (API requests) or there should be some additional data preparation done, it is recommended to provide custom and agnostic static methods: ```ts{5-10} class User extends Data { name: string = "Anonymous"; age: number = 0; // custom method with arbitrary interface static from(data: Object): User { let name: string = data.name; let age: number = parseInt(data.age, 10); return User.create({ name, age }); } } let user = User.from({ name: "Liza", age: "18", someUnusedFlag: true }); ``` That's how native things handle these cases: see [`Array.from()`](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Array/from). ================================================ FILE: docs/index.md ================================================ --- layout: home hero: name: dataclass # text: lorem ipsum tagline: Data Classes for TypeScript & JavaScript actions: - theme: brand text: Get Started link: /guide/ - theme: alt text: GitHub link: https://github.com/alexeyraspopov/dataclass home: true tagline: Data Classes for TypeScript & JavaScript actions: - text: Get Started link: /guide/ type: primary - text: GitHub link: https://github.com/alexeyraspopov/dataclass type: secondary features: - icon: 🪶 title: Lightweight details: The library takes less than 500B in your bundle (min+gzip) while still providing a lot of flexibility and typings - icon: 🧱 title: Immutable details: The power of value objects is based on a simple convention that objects never mutate - icon: ✨ title: Delightful details: The project is built with developer experience in mind. Coding should be easy and dataclass is here to help --- ================================================ FILE: docs/package.json ================================================ { "scripts": { "start": "vitepress dev .", "build": "vitepress build ." }, "dependencies": { "vitepress": "^1.0.0-rc.4" } } ================================================ FILE: docs/reference/index.md ================================================ # API Reference ### class `Data` Base class for domain models. Should be extended with a set of class fields that describe the shape of desired model. #### Example ```ts import { Data } from "dataclass"; class Project extends Data { id: string = ""; name: string = "Untitled Project"; createdBy: string = ""; createdAt: Date | null = null; } ``` ### static `create(values)` Once extended, data class can be instantiated with a new data. That's the way to get a unique immutable persistent model. #### Arguments 1. `values` (_Object_): POJO which shape satisfy the contract described during class extension. If you use [Flow](https://flow.org), it will warn you about the mistakes. #### Returns (_Data_): an instance of your data class with all the defined fields accessible as in the plain object. Properties are read only. #### Example ```ts class Vehicle extends Data { model: string = "Unspecified"; manufacturer: string = "Unknown"; } let vehicle = Vehicle.create({ manufacturer: "Tesla", model: "S" }); // > Vehicle { manufacturer: 'Tesla', model: 'S' } vehicle.manufacturer; // > 'Tesla' ``` ### method `copy(values)` Create new immutable instance based on an existent one. Since properties are read only, that's the way to provide an updated model's fields to a consumer keeping the rest unchanged. #### Arguments 1. `values` (_Data_): POJO that includes new values that you want to change. Properties should satisfy the contract described by the class. #### Returns (_Data_): new instance of the same type and with new values. #### Example ```ts class User extends Data { name: string = "Anonymous"; email: string | null = null; } let user = User.create({ name: "Liza" }); // > User { name: 'Liza', email: null } let updated = user.copy({ email: "liza@example.com" }); // > User { name: 'Liza', email: 'liza@example.com' } ``` ### method `equals(other)` Since immutable instances always have not equal references, there should be a way to compare the actual values. #### Arguments 1. `other` (_Object_): a data object of the same class as a target one. #### Returns (_Boolean_): `false` if some field value is not [strictly equal](https://www.ecma-international.org/ecma-262/5.1/#sec-11.9.6) in both instances. `true` otherwise. #### Example ```ts class Box extends Data { size: number = 16; color: string = "red"; } let first = Box.create({ color: "green" }); let second = Box.create({ color: "blue" }); let third = first.copy({ color: "blue" }); first === second; // > false first === third; // > false first.equals(second); // > false second.equals(third); // > true ``` ================================================ FILE: integration/Data.test.ts ================================================ import { test } from "vitest"; import { deepEqual, equal, throws } from "node:assert/strict"; import { Data } from "dataclass"; class Entity extends Data { someString: string = "default string"; someNum: number = 0.134; someBool: boolean = true; someNullable: string | null = null; get exclamation() { return this.someString + "!"; } } function plain(target: object) { return Object.fromEntries(Object.entries(target)); } test("should create an entity with default values", () => { let entity = Entity.create(); deepEqual(plain(entity), { someString: "default string", someNum: 0.134, someBool: true, someNullable: null, }); }); test("should override defaults with custom values", () => { let entity = Entity.create({ someNullable: "1", someString: "hello" }); deepEqual(plain(entity), { someString: "hello", someNum: 0.134, someBool: true, someNullable: "1", }); }); test("should satisfy composition law", () => { let entity = Entity.create(); let left = entity.copy({ someNum: 13, someBool: false }); let right = entity.copy({ someNum: 13 }).copy({ someBool: false }); deepEqual(left, right); equal(left.equals(right), true); }); test("should support subclassing", () => { class SubEntity extends Entity { someNewThing: string = "default"; } let entityA = SubEntity.create(); let entityB = SubEntity.create({ someString: "test", someNewThing: "blah" }); deepEqual(plain(entityA), { someString: "default string", someNum: 0.134, someBool: true, someNullable: null, someNewThing: "default", }); deepEqual(plain(entityB), { someString: "test", someNum: 0.134, someBool: true, someNullable: null, someNewThing: "blah", }); }); test("should support polymorphism", () => { class Base extends Data { format: string = "AAA"; transform(value: string) { return this.format.replace(/A/g, value); } } class Child extends Base { transform(value: string) { return "-" + this.format.replace(/A/g, value); } } let baseEntity = Base.create({ format: "AAAAA" }); let childEntity = Child.create(); equal(baseEntity.transform("1"), "11111"); equal(childEntity.transform("1"), "-111"); }); test("should create new entity based on existent", () => { let entity = Entity.create({ someBool: false }); let updated = entity.copy({ someNum: 14 }); deepEqual(plain(entity), { someString: "default string", someNum: 0.134, someBool: false, someNullable: null, }); deepEqual(plain(updated), { someString: "default string", someNum: 14, someBool: false, someNullable: null, }); }); test("should compare custom values for two entities of the same type", () => { let entityA = Entity.create({ someBool: false, someNullable: null }); let equalE = Entity.create({ someBool: false, someNum: 0.134 }); let unequal = Entity.create({ someBool: false, someNullable: undefined }); let entityB = Entity.create({ someNullable: "1" }); let entityC = Entity.create({ someNullable: null }); let extended = entityB.copy({ someBool: true }); let updated = entityA.copy({ someNum: 14 }); equal(entityA.equals(updated), false); equal(entityA.equals(equalE), true); equal(unequal.equals(equalE), false); equal(entityB.equals(extended), true); equal(entityB.equals(entityA), false); equal(entityB.equals(entityC), false); }); class Embedded extends Data { name: string = "name"; age: number = 1; entity: Entity | null = Entity.create(); date: Date = new Date(); obj: Object | null = { foo: "bar" }; } test("should be serializable with embedded dataclass", () => { let dummyDate = new Date("1996-12-17T03:24:00"); let embedded = Embedded.create({ date: dummyDate }); let raw = { name: "name", age: 1, entity: { someString: "default string", someNum: 0.134, someBool: true, someNullable: null, }, date: dummyDate.toISOString(), obj: { foo: "bar", }, }; equal(JSON.stringify(embedded), JSON.stringify(raw)); }); test("should compare dataclass with nested value objects", () => { let embeddedA = Embedded.create({ date: new Date("1996-12-17T03:24:00"), entity: Entity.create({ someBool: false }), obj: null, }); let embeddedB = Embedded.create({ date: new Date("1996-12-17T03:24:00"), entity: Entity.create({ someBool: false }), obj: null, }); let embeddedC = Embedded.create({ date: new Date("1996-12-17T03:24:00"), entity: Entity.create({ someBool: true }), }); let embeddedD = Embedded.create({ date: new Date("2001-12-17T03:24:00"), entity: Entity.create({ someBool: true }), }); let embeddedE = Embedded.create({ date: new Date("2001-12-17T03:24:00"), entity: null, }); equal(embeddedA.equals(embeddedB), true); equal(embeddedB.equals(embeddedC), false); equal(embeddedC.equals(embeddedD), false); equal(embeddedD.equals(embeddedE), false); }); test("should satisfy symmetry law", () => { let a = Entity.create({ someString: "1" }); let b = Entity.create({ someString: "1" }); let c = Entity.create({ someString: "2" }); equal(a.equals(b), true); equal(b.equals(a), true); equal(a.equals(c), false); equal(c.equals(a), false); }); test("should satisfy transitivity law", () => { let a = Entity.create({ someString: "hello" }); let b = Entity.create({ someString: "hello" }); let c = Entity.create({ someString: "hello" }); equal(a.equals(b), true); equal(b.equals(c), true); equal(a.equals(c), true); }); test("should support iterables", () => { let entity = Entity.create({ someBool: false }); deepEqual(Object.entries(entity), [ ["someString", "default string"], ["someNum", 0.134], ["someBool", false], ["someNullable", null], ]); deepEqual(Object.keys(entity), ["someString", "someNum", "someBool", "someNullable"]); deepEqual(Object.values(entity), ["default string", 0.134, false, null]); }); test("should not allow assignment", () => { let entity = Entity.create({ someBool: false }); equal(Object.isFrozen(entity), true); throws(() => { entity.someBool = true; }, /Cannot assign/); throws(() => { // @ts-ignore intentional addition of inexistent property to assert runtime error entity.somethingElse = null; }, /Cannot add property/); }); test("should prohibit new properties", () => { let entity = Entity.create({ someBool: false }); equal(Object.isSealed(entity), true); throws(() => { // @ts-ignore intentional addition of inexistent property to assert runtime error Entity.create({ thisShouldNotBeHere: 1 }); }, /object is not extensible/); throws(() => { // @ts-ignore intentional addition of inexistent property to assert runtime error Entity.create().copy({ thisShouldNotBeHere: 1 }); }, /object is not extensible/); }); test("should support predefined getters", () => { let entity = Entity.create({ someString: "abcde" }); equal(entity.exclamation, "abcde!"); }); test("should disallow use of constructor", () => { throws(() => { new Entity(); }, /Use Entity.create/); }); test("should allow dynamic defaults per instance", () => { class Ent extends Data { id: string = Math.random().toString(16).slice(2, 8); } let a1 = Ent.create(); let a2 = a1.copy(); let b = Ent.create(); equal(a1.equals(a2), true); equal(b.equals(a1), false); equal(b.equals(a2), false); }); ================================================ FILE: integration/integration.js ================================================ import { test } from "node:test"; import { deepEqual, equal, throws } from "node:assert/strict"; import { Data } from "dataclass"; class Entity extends Data { someString = "default string"; someNum = 0.134; someBool = true; someNullable = null; get exclamation() { return this.someString + "!"; } } test("should create an entity with default values", () => { let entity = Entity.create(); matches(entity, { someString: "default string", someNum: 0.134, someBool: true, someNullable: null, }); }); test("should override defaults with custom values", () => { let entity = Entity.create({ someNullable: "1", someString: "hello" }); matches(entity, { someString: "hello", someNum: 0.134, someBool: true, someNullable: "1", }); }); test("should satisfy composition law", () => { let entity = Entity.create(); let left = entity.copy({ someNum: 13, someBool: false }); let right = entity.copy({ someNum: 13 }).copy({ someBool: false }); deepEqual(left, right); equal(left.equals(right), true); }); test("should support subclassing", () => { class SubEntity extends Entity { someNewThing = "default"; } let entityA = SubEntity.create(); let entityB = SubEntity.create({ someString: "test", someNewThing: "blah" }); matches(entityA, { someString: "default string", someNum: 0.134, someBool: true, someNullable: null, someNewThing: "default", }); matches(entityB, { someString: "test", someNum: 0.134, someBool: true, someNullable: null, someNewThing: "blah", }); }); test("should support polymorphism", () => { class Base extends Data { format = "AAA"; transform(value) { return this.format.replace(/A/g, value); } } class Child extends Base { transform(value) { return "-" + this.format.replace(/A/g, value); } } let baseEntity = Base.create({ format: "AAAAA" }); let childEntity = Child.create(); equal(baseEntity.transform("1"), "11111"); equal(childEntity.transform("1"), "-111"); }); test("should create new entity based on existent", () => { let entity = Entity.create({ someBool: false }); let updated = entity.copy({ someNum: 14 }); matches(entity, { someString: "default string", someNum: 0.134, someBool: false, someNullable: null, }); matches(updated, { someString: "default string", someNum: 14, someBool: false, someNullable: null, }); }); test("should compare custom values for two entities of the same type", () => { let entityA = Entity.create({ someBool: false, someNullable: null }); let equalE = Entity.create({ someBool: false, someNum: 0.134 }); let unequal = Entity.create({ someBool: false, someNullable: undefined }); let entityB = Entity.create({ someNullable: "1" }); let entityC = Entity.create({ someNullable: null }); let extended = entityB.copy({ someBool: true }); let updated = entityA.copy({ someNum: 14 }); equal(entityA.equals(updated), false); equal(entityA.equals(equalE), true); equal(unequal.equals(equalE), false); equal(entityB.equals(extended), true); equal(entityB.equals(entityA), false); equal(entityB.equals(entityC), false); }); class Embedded extends Data { name = "name"; age = 1; entity = Entity.create(); date = new Date(); obj = { foo: "bar" }; } test("should be serializable with embedded dataclass", () => { let dummyDate = new Date("1996-12-17T03:24:00"); let embedded = Embedded.create({ date: dummyDate }); let raw = { name: "name", age: 1, entity: { someString: "default string", someNum: 0.134, someBool: true, someNullable: null, }, date: dummyDate.toISOString(), obj: { foo: "bar", }, }; equal(JSON.stringify(embedded), JSON.stringify(raw)); }); test("should compare dataclass with nested value objects", () => { let embeddedA = Embedded.create({ date: new Date("1996-12-17T03:24:00"), entity: Entity.create({ someBool: false }), obj: null, }); let embeddedB = Embedded.create({ date: new Date("1996-12-17T03:24:00"), entity: Entity.create({ someBool: false }), obj: null, }); let embeddedC = Embedded.create({ date: new Date("1996-12-17T03:24:00"), entity: Entity.create({ someBool: true }), }); let embeddedD = Embedded.create({ date: new Date("2001-12-17T03:24:00"), entity: Entity.create({ someBool: true }), }); let embeddedE = Embedded.create({ date: new Date("2001-12-17T03:24:00"), entity: null, }); equal(embeddedA.equals(embeddedB), true); equal(embeddedB.equals(embeddedC), false); equal(embeddedC.equals(embeddedD), false); equal(embeddedD.equals(embeddedE), false); }); test("should satisfy symmetry law", () => { let a = Entity.create({ someString: "1" }); let b = Entity.create({ someString: "1" }); let c = Entity.create({ someString: "2" }); equal(a.equals(b), true); equal(b.equals(a), true); equal(a.equals(c), false); equal(c.equals(a), false); }); test("should satisfy transitivity law", () => { let a = Entity.create({ someString: "hello" }); let b = Entity.create({ someString: "hello" }); let c = Entity.create({ someString: "hello" }); equal(a.equals(b), true); equal(b.equals(c), true); equal(a.equals(c), true); }); test("should support iterables", () => { let entity = Entity.create({ someBool: false }); deepEqual(Object.entries(entity), [ ["someString", "default string"], ["someNum", 0.134], ["someBool", false], ["someNullable", null], ]); deepEqual(Object.keys(entity), ["someString", "someNum", "someBool", "someNullable"]); deepEqual(Object.values(entity), ["default string", 0.134, false, null]); }); test("should not allow assignment", () => { let entity = Entity.create({ someBool: false }); equal(Object.isFrozen(entity), true); throws(() => { entity.someBool = true; }, /Cannot assign/); throws(() => { // @ts-ignore intentional addition of inexistent property to assert runtime error entity.somethingElse = null; }, /Cannot add property/); }); test("should prohibit new properties", () => { let entity = Entity.create({ someBool: false }); equal(Object.isSealed(entity), true); throws(() => { // @ts-ignore intentional addition of inexistent property to assert runtime error Entity.create({ thisShouldNotBeHere: 1 }); }, /object is not extensible/); throws(() => { // @ts-ignore intentional addition of inexistent property to assert runtime error Entity.create().copy({ thisShouldNotBeHere: 1 }); }, /object is not extensible/); }); test("should support predefined getters", () => { let entity = Entity.create({ someString: "abcde" }); equal(entity.exclamation, "abcde!"); }); test("should disallow use of constructor", () => { throws(() => { new Entity(); }, /Use Entity.create/); }); test("should allow dynamic defaults per instance", () => { class Ent extends Data { id = Math.random().toString(16).slice(2, 8); } let a1 = Ent.create(); let a2 = a1.copy(); let b = Ent.create(); equal(a1.equals(a2), true); equal(b.equals(a1), false); equal(b.equals(a2), false); }); function matches(entity, object, message) { deepEqual(plain(entity), object, message); } function plain(target) { return Object.fromEntries(Object.entries(target)); } ================================================ FILE: integration/legacy.json ================================================ { "compilerOptions": { "target": "es2017", "module": "commonjs", "useDefineForClassFields": false, "esModuleInterop": true, "strict": true, "noImplicitAny": true, "skipLibCheck": true, "allowJs": true } } ================================================ FILE: integration/modern.json ================================================ { "compilerOptions": { "target": "es2022", "module": "commonjs", "useDefineForClassFields": true, "esModuleInterop": true, "strict": true, "noImplicitAny": true, "skipLibCheck": true, "allowJs": true } } ================================================ FILE: integration/runtime.test.ts ================================================ import { test, expectTypeOf } from "vitest"; import { deepEqual, throws } from "node:assert/strict"; import { Data } from "dataclass"; import { runtime, data } from "dataclass/runtime"; test("throws", () => { @runtime class Entity extends Data { name = data.string("", data.required); things = data.union([data.string(), data.number()], 1); } // @ts-expect-error throws(() => Entity.create(), /is required but value was not provided/); // @ts-expect-error throws(() => Entity.create({ name: 123 }), /expected to be of type/); matches(Entity.create({ name: "Liza" }), { name: "Liza", things: 1 }); }); test("defaults", () => { @runtime class Entity extends Data { prop = data.number(13); } // @ts-expect-error throws(() => Entity.create({ prop: "boo" }), /expected to be of type/); matches(Entity.create(), { prop: 13 }); let entity = Entity.create(); expectTypeOf(entity.prop).toBeNumber(); }); test("inherited", () => { @runtime class Base extends Data { name = data.string(); // blab = data.instance>(Promise); } class Entity extends Base { age: number | null = null; } // @ts-expect-error throws(() => Entity.create({}), /is required but value was not provided/); }); function matches(entity: Data, object: object, message?: string) { deepEqual(plain(entity), object, message); } function plain(target: object) { return Object.fromEntries(Object.entries(target)); } ================================================ FILE: integration/spec.json ================================================ { "compilerOptions": { "target": "es2020", "module": "commonjs", "useDefineForClassFields": true, "esModuleInterop": true, "strict": true, "noImplicitAny": true, "skipLibCheck": true, "allowJs": true } } ================================================ FILE: modules/Data.js ================================================ let O = Object; let produce = (proto, base, values) => O.freeze(O.assign(O.seal(O.assign(O.create(proto), base)), values)); export class Data { static create(values) { return produce(this.prototype, new this(Data), values); } constructor(values) { if (values !== Data) { throw new Error(`Use ${this.constructor.name}.create(...) instead of new operator`); } } copy(values) { return produce(O.getPrototypeOf(this), this, values); } equals(other) { for (let key in this) { let a = this[key]; let b = other[key]; if ( !Object.is(a, b) && (a == null || b == null || (a instanceof Data && b instanceof Data ? !a.equals(b) : !Object.is(a.valueOf(), b.valueOf()))) ) return false; } return true; } } ================================================ FILE: modules/runtime.js ================================================ import { Data } from "./Data.js"; export function runtime(Class) { if (!Data.prototype.isPrototypeOf(Class.prototype)) { throw new Error("Provided class is not a subclass of Data"); } let self = new Class(Data); Object.defineProperty(Class, "create", { value(values = {}) { // this will not let me check for Fields in inherited properties // is it though? those are still own properties — needs a test for (let key in self) { if (self[key] instanceof Field) { let field = self[key]; if (field.required && !(key in values)) { throw new Error(`${key} is required but value was not provided.`); } if (key in values && !field.validate(values[key])) { let value = JSON.stringify(values[key]); throw new Error( `${key} expected to be of type ${field.label}, but received value ${value}.`, ); } if (!(key in values)) { values[key] = field.defaults; } } } return Object.getPrototypeOf(this).create.call(Class, values); }, }); Object.defineProperty(Class.prototype, "copy", { value(values) { if (values != null) { for (let key in self) { if (self[key] instanceof Field) { let field = self[key]; if (key in values && !field.validate(values[key])) { let value = JSON.stringify(values[key]); throw new Error( `${key} expected to be of type ${field.label}, but received value ${value}.`, ); } } } } return Object.getPrototypeOf(this).copy.call(this, values); }, }); return Class; } class Field { constructor(label, defaults, options) { this.label = label; this.defaults = defaults; this.required = options != null && "required" in options ? options.required : typeof defaults === "undefined"; this.nullable = false; } validate(value) { return this.nullable && value === null; } } class PrimitiveField extends Field { validate(value) { return super.validate(value) || typeof value === this.label; } } class InstanceField extends Field { constructor(Class, defaults) { super("instance", defaults); this.Class = Class; } validate(value) { return super.validate(value) || value instanceof Class; } } class LiteralField extends Field { validate(value) { return super.validate(value) || Object.is(value, this.defaults); } } class UnionField extends Field { constructor(fields, defaults) { super(`union(${fields.map((field) => field.label).join(", ")})`, defaults); this.fields = fields; } validate(value) { return super.validate(value) || this.fields.some((field) => field.validate(value)); } } class UnknownField extends Field { validate() { return true; } } class ObjectField extends Field { constructor(schema, defaults, options) { super("object", defaults, options); this.schema = schema; } validate(value) { if (super.validate(value)) return true; if (value == null || typeof value != "object") return false; for (let key in this.schema) { if (!(key in value) && !this.schema[key].validate(value[key])) return false; } for (let key in value) { if (!this.schema[key].validate(value[key])) return false; } } } class ArrayField extends Field { constructor(schema, defaults, options) { super("array", defaults, options); this.schema = schema; } validate(value) { if (super.validate(value)) return true; if (value == null || !Array.isArray(value)) return false; for (let item of value) { if (!this.schema.some((field) => field.validate(item))) { return false; } } } } export let data = { string: (defaults, options) => new PrimitiveField("string", defaults, options), number: (defaults, options) => new PrimitiveField("number", defaults, options), boolean: (defaults, options) => new PrimitiveField("boolean", defaults, options), bigint: (defaults, options) => new PrimitiveField("bigint", defaults, options), symbol: (defaults, options) => new PrimitiveField("symbol", defaults, options), literal: (defaults) => new LiteralField(typeof defaults, defaults), undefined: () => data.literal(void 0), null: () => data.literal(null), instance: (Class, defaults) => new InstanceField(Class, defaults), regexp: (defaults) => data.instance(RegExp, defaults), date: (defaults) => data.instance(Date, defaults), union: (fields, defaults) => new UnionField(fields, defaults), unknown: (defaults) => new UnknownField("unknown", defaults), required: { required: true }, optional: { optional: true }, nullable: { nullable: true }, object: (schema, defaults, options) => new ObjectField(schema, defaults, options), array: (schema, defaults, options) => new ArrayField(schema, defaults, options), // map, set // record, tuple? }; ================================================ FILE: package.json ================================================ { "name": "dataclass", "version": "3.0.0-beta.1", "description": "Data classes for TypeScript & JavaScript", "author": "Oleksii Raspopov", "license": "ISC", "homepage": "https://dataclass.js.org", "repository": "alexeyraspopov/dataclass", "main": "./dataclass.js", "module": "./dataclass.module.js", "types": "./dataclass.d.ts", "sideEffects": false, "files": [ "dataclass.*" ], "keywords": [ "dataclass", "immutable", "value-objects", "data-structures", "typings" ], "scripts": { "test": "vitest", "integration": "tsx --tsconfig integration/$TARGET.json --experimental-test-coverage integration/integration.js", "build": "rollup --config rollup.config.mjs" }, "devDependencies": { "@vitest/coverage-istanbul": "^1.5.0", "flow-bin": "^0.234.0", "prettier": "^2.4.1", "rollup": "^4.15.0", "rollup-plugin-copy": "^3.4.0", "tsx": "^4.7.2", "typescript": "^5.4.5", "vitest": "^1.5.0" }, "prettier": { "printWidth": 100, "trailingComma": "all", "proseWrap": "always" } } ================================================ FILE: rollup.config.mjs ================================================ import { defineConfig } from "rollup"; import copy from "rollup-plugin-copy"; import { extname } from "node:path"; let destination = "node_modules/dataclass"; export default defineConfig({ input: ["modules/Data.js", "modules/runtime.js"], output: [ { dir: destination, format: "cjs", entryFileNames: ({ name }) => (name === "Data" ? "dataclass.js" : "[name].js"), }, { dir: destination, format: "esm", entryFileNames: ({ name }) => (name === "Data" ? "dataclass.module.js" : "[name].module.js"), }, ], plugins: [ copy({ targets: [ { src: ["typings/*", "LICENSE"], dest: destination }, { src: "typings/*", dest: destination, rename: (name, extension) => name.endsWith(".d") || name.endsWith(".js") ? `${name.slice(0, name.lastIndexOf("."))}.module${extname(name)}.${extension}` : `${name}.module.${extension}`, }, { src: "README.md", dest: destination, transform: generateReadme }, { src: "package.json", dest: destination, transform: generatePkg }, ], }), ], }); function generatePkg(contents) { let pkg = JSON.parse(contents.toString()); return JSON.stringify( { name: pkg.name, version: pkg.version, description: pkg.description, author: pkg.author, license: pkg.license, homepage: pkg.homepage, repository: pkg.repository, main: pkg.main, module: pkg.module, exports: { ".": "./dataclass.module.js", "./runtime": "./runtime.module.js", }, types: pkg.types, sideEffects: pkg.sideEffects, files: pkg.files, keywords: pkg.keywords, }, null, 2, ); } function generateReadme() { return ` The library brings flexibility and usefulness of data classes from Kotlin, Scala, or Python to TypeScript and JavaScript. Read full docs [on the homepage](https://dataclass.js.org). \`\`\`javascript import { Data } from "dataclass"; // 1. easily describe your data classes using just language features class User extends Data { name: string = "Anon"; age: number = 0; } // 2. instantiate classes while type systems ensure correctness let user = User.create({ name: "Liza", age: 23 }); // > User { name: "Liza", age: 23 } // 3. make changes while benefiting from immutable values let updated = user.copy({ name: "Ann" }); // > User { name: "Ann", age: 23 } updated = updated.copy({ name: "Liza" }); // > User { name: "Liza", age: 23 } // 4. compare objects by their value, not reference console.log(user === updated, user.equals(updated)); // > false, true \`\`\` `.trimStart(); } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "es2022", "module": "commonjs", "moduleResolution": "bundler", "useDefineForClassFields": true, "esModuleInterop": true, "strict": true, "noImplicitAny": true, "skipLibCheck": true, "allowJs": true, "experimentalDecorators": true } } ================================================ FILE: typings/dataclass.d.ts ================================================ interface S { /** If you see this, the property is coming from a dataclass instance and was required */ valueOf(): 2147483647; } /** * Special generic type to mark data class field as required to be initialized * at creation time. Can be used along with ! (exclamation mark operator) to * define a field without a default value, but explicit type and make `create()` * method require this field to be provided. */ export type Enforced = T & S; type Unwrap = T extends Enforced ? V : T; type OwnKey = K extends keyof Data ? never : K; type ANY = never; type EnforcedFields = Required<{ [P in keyof T as T[P] extends Enforced ? T[P] extends ANY ? unknown : OwnKey

: never]: Unwrap; }>; type ExplicitFields = Partial<{ [P in keyof T as T[P] extends Enforced ? T[P] extends ANY ? OwnKey

: never : OwnKey

]: T[P]; }>; type Init = EnforcedFields & ExplicitFields; /** * Abstract class that allows defining custom data classes. Should be extended * with a set of class fields that define the shape of desired model. * * ```ts * import { Data, type Enforced } from "dataclass"; * * class Project extends Data { * // this property is required when creating an instance * id: Enforced; * // these properties have defaults but can be overwritten * name: string = "Untitled"; * createdBy: string = "Anon"; * // this property may contain null and won't be required * createdAt: Date | null = null; * } * * let project = Project.create({ * id: 'abc123', * createdBy: 'Oleksii', * }); * // > Project { id: 'abc123', name: 'Untitled', createdBy: 'Oleksii', createdAt: null } * ``` * * @link https://dataclass.js.org */ export class Data { /** * Instantiate the data class. Provide custom values that should override * defaults. If the class has optional properties, create() method will * require to explicitly define them. * * ```ts * class User extends Data { * name: string = "Anon"; * age: number | null = null; * } * ``` * * @link https://dataclass.js.org/guide/getting-started.html */ static create( this: { new (): Type }, ...values: EnforcedFields extends Record ? [Init?] : [Init] ): Type; /** * Create new immutable instance based on existing one, with some properties changed. * * ```ts * class User extends Data { * name: string = "Anon"; * age: number | null = null; * } * * let initial = User.create({ name: "Liza" }); * * // creates an immutable copy with previously defined * // `name: "Liza"` and additionaly defined `age: 28` * let updated = initial.copy({ age: 28 }); * ``` */ copy(values?: Partial>): this; /** * Compare the instance to another instance of the same data class. * * @link https://dataclass.js.org/guide/objects-equality.html */ equals(other: this): boolean; } ================================================ FILE: typings/dataclass.js.flow ================================================ /* @flow */ /** * Special generic type to mark data class field as required to be initialized * at creation time. Can be used along with ! (exclamation mark operator) to * define a field without a default value, but explicit type and make `create()` * method require this field to be provided. */ declare export opaque type Enforced : T; declare type OptKeys = $Values<{ [P in $Keys]: T[P] extends Enforced ? P : empty}>; declare type EnforcedFields = Pick<{ [P in $Keys]: T[P] extends Enforced ? V : T[P] }, OptKeys>; declare type ExplicitFields = Omit<{ [P in $Keys]: T[P] extends Enforced ? V : T[P] }, OptKeys>; // do I still need Required for Optional part? declare type Values = { ...EnforcedFields, ...Partial> }; declare type Init = EnforcedFields extends Record ? Values | void : Values; declare type Shape = Partial>; /** * Abstract class that allows defining custom data classes. Should be extended * with a set of class fields that define the shape of desired model. * * ```js * // @flow * import { Data, type Enforced } from "dataclass"; * * class Project extends Data { * // this property is required when creating an instance * id: Enforced; * // these properties have defaults but can be overwritten * name: string = "Untitled"; * createdBy: string = "Anon"; * // this property may contain null and won't be required * createdAt: Date | null = null; * } * * let project = Project.create({ * id: 'abc123', * createdBy: 'Oleksii', * }); * // > Project { id: 'abc123', name: 'Untitled', createdBy: 'Oleksii', createdAt: null } * ``` * * @link https://dataclass.js.org */ declare export class Data { /** * Instantiate the data class. Provide custom values that should override * defaults. If the class has optional properties, create() method will * require to explicitly define them. * * ```js * // @flow * * class User extends Data { * name: string = "Anon"; * age: number | null = null; * } * ``` * * @link https://dataclass.js.org/guide/getting-started.html */ static create(values: Init): this; /** * Create new immutable instance based on existing one, with some properties changed. * * ```js * // @flow * * class User extends Data { * name: string = "Anon"; * age: number | null = null; * } * * let initial = User.create({ name: "Liza" }); * * // creates an immutable copy with previously defined * // `name: "Liza"` and additionaly defined `age: 28` * let updated = initial.copy({ age: 28 }); * ``` */ copy(values?: Shape): this; /** * Compare the instance to another instance of the same data class. * * @link https://dataclass.js.org/guide/objects-equality.html */ equals(other: this): boolean; } ================================================ FILE: typings/runtime.d.ts ================================================ import { Enforced } from "./dataclass"; export function runtime(C: T, ...args: any): T; interface TypedField { (): Enforced; (defaults: Type): Type; (defaults?: Type, options: { required: true }): Enforced; (defaults?: Type, options: { optional: true }): Type | undefined; (defaults?: Type, options: { nullable: true }): Type | null; } interface LiteralField { (defaults: Type): Type; } interface ConstField { (): Value; (options: { required: true }): Enforced; } interface InstanceField { (Ctor: { new (...args: any): Class }, defaults?: Class): Class; } // how do I make it possible to use null|undefined interface UnionField { (fields: T): Enforced< T extends Array ? (V extends Enforced ? VV : V) : T >; ( fields: T, defaults: T extends Array ? (V extends Enforced ? VV : V) : T, ): T extends Array ? (V extends Enforced ? VV : V) : T; ( fields: T, defaults?: T extends Array ? (V extends Enforced ? VV : V) : T, options: { required: true }, ): Enforced ? (V extends Enforced ? VV : V) : T>; ( fields: T, defaults?: T extends Array ? (V extends Enforced ? VV : V) : T, options: { optional: true }, ): (T extends Array ? (V extends Enforced ? VV : V) : T) | undefined; ( fields: T, defaults?: T extends Array ? (V extends Enforced ? VV : V) : T, options: { nullable: true }, ): (T extends Array ? (V extends Enforced ? VV : V) : T) | null; } interface ObjectField { >(schema: Type): Enforced<{ [P in keyof Type]: Type[P] extends Enforced ? V : Type[P]; }>; >( schema: Type, defaults: NoInfer<{ [P in keyof Type]: Type[P] extends Enforced ? V : Type[P] }>, ): { [P in keyof Type]: Type[P] extends Enforced ? V : Type[P] }; >( schema: Type, defaults?: NoInfer<{ [P in keyof Type]: Type[P] extends Enforced ? V : Type[P] }>, options: { required: true }, ): Enforced<{ [P in keyof Type]: Type[P] extends Enforced ? V : Type[P] }>; >( schema: Type, defaults?: NoInfer<{ [P in keyof Type]: Type[P] extends Enforced ? V : Type[P] }>, options: { optional: true }, ): { [P in keyof Type]: Type[P] extends Enforced ? V : Type[P] } | undefined; >( schema: Type, defaults?: NoInfer<{ [P in keyof Type]: Type[P] extends Enforced ? V : Type[P] }>, options: { nullable: true }, ): { [P in keyof Type]: Type[P] extends Enforced ? V : Type[P] } | null; } interface ArrayField { (schema: Type): Enforced< (Type extends Array ? (V extends Enforced ? VV : V) : Type)[] >; ( schema: Type, defaults: NoInfer< (Type extends Array ? (V extends Enforced ? VV : V) : Type)[] >, ): (Type extends Array ? (V extends Enforced ? VV : V) : Type)[]; ( schema: Type, defaults?: NoInfer< (Type extends Array ? (V extends Enforced ? VV : V) : Type)[] >, options: { required: true }, ): Enforced<(Type extends Array ? (V extends Enforced ? VV : V) : Type)[]>; ( schema: Type, defaults?: NoInfer< (Type extends Array ? (V extends Enforced ? VV : V) : Type)[] >, options: { optional: true }, ): (Type extends Array ? (V extends Enforced ? VV : V) : Type)[] | undefined; ( schema: Type, defaults?: NoInfer< (Type extends Array ? (V extends Enforced ? VV : V) : Type)[] >, options: { nullable: true }, ): (Type extends Array ? (V extends Enforced ? VV : V) : Type)[] | null; } interface DataFields { string: TypedField; number: TypedField; boolean: TypedField; bigint: TypedField; symbol: TypedField; literal: LiteralField; null: ConstField; undefined: ConstField; // can i use return type to get result of applying Class to TypedField? instance: InstanceField; regexp: TypedField; date: TypedField; union: UnionField; unknown: ConstField; // should have required option required: { required: true }; optional: { optional: true }; nullable: { nullable: true }; object: ObjectField; array: ArrayField; } export let data: DataFields; ================================================ FILE: typings/runtime.js.flow ================================================ /* @flow */ declare export function runtime(options?: {}): (C: T) => T; declare export let data: Record = {}; ================================================ FILE: vitest.config.ts ================================================ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { coverage: { enabled: true, provider: "istanbul", }, }, });