UNPKG

@ayka/strukt

Version:

A lightweight TypeScript library that simplifies the creation of structured data objects and classes. It provides a type-safe and flexible way to define classes with custom properties and initialization logic.

767 lines (585 loc) 22.8 kB
# @ayka/strukt <!-- Package --> [npm-url]: https://www.npmjs.com/package/@ayka/strukt [npm-next-url]: https://www.npmjs.com/package/@ayka/strukt/v/next [npm-version-badge]: https://img.shields.io/npm/v/%40ayka%2Fstrukt/latest [npm-version-next-badge]: https://img.shields.io/npm/v/%40ayka%2Fstrukt/next [npm-downloads-badge]: https://img.shields.io/npm/dm/%40ayka%2Fstrukt [npm-unpacked-size-badge]: https://img.shields.io/npm/unpacked-size/%40ayka%2Fstrukt [bundle-js-url]: https://bundlejs.com/?q=%40ayka%2Fstrukt [bundle-js-badge]: https://img.shields.io/bundlejs/size/%40ayka%2Fstrukt <!-- GitHub Actions --> [actions-ci]: https://github.com/AndreyMork/strukt/actions/workflows/ci.yaml [actions-codeql]: https://github.com/AndreyMork/strukt/actions/workflows/github-code-scanning/codeql [actions-ci-badge]: https://github.com/AndreyMork/strukt/actions/workflows/ci.yaml/badge.svg [actions-codeql-badge]: https://github.com/AndreyMork/strukt/actions/workflows/github-code-scanning/codeql/badge.svg <!-- Code Climate --> [codeclimate-url]: https://codeclimate.com/github/AndreyMork/strukt [codeclimate-maintainability-badge]: https://api.codeclimate.com/v1/badges/6b269725db17b3e72636/maintainability [codeclimate-test-coverage-badge]: https://api.codeclimate.com/v1/badges/6b269725db17b3e72636/test_coverage <!-- Misc --> [license-url]: https://opensource.org/license/MIT [license-badge]: https://img.shields.io/npm/l/%40ayka%2Fstrukt [mutation-testing-badge]: https://img.shields.io/endpoint?url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2FAndreyMork%2Fstrukt%2Fmain [mutation-testing-url]: https://dashboard.stryker-mutator.io/reports/github.com/AndreyMork/strukt/main <!-- Badges --> [![NPM Version][npm-version-badge]][npm-url] [![NPM Version][npm-version-next-badge]][npm-next-url] [![NPM License][license-badge]][license-url] [![NPM Downloads][npm-downloads-badge]][npm-url] [![npm package minimized gzipped size][bundle-js-badge]][bundle-js-url] [![NPM Unpacked Size][npm-unpacked-size-badge]][npm-url] [![CI][actions-ci-badge]][actions-ci] [![CodeQL][actions-codeql-badge]][actions-codeql] [![Maintainability][codeclimate-maintainability-badge]][codeclimate-url] [![Test Coverage][codeclimate-test-coverage-badge]][codeclimate-url] [![Mutation testing badge][mutation-testing-badge]][mutation-testing-url] ## Overview `@ayka/strukt` is a lightweight TypeScript library that simplifies the creation of structured data objects and classes. It provides a type-safe and flexible way to define classes with custom properties and initialization logic. The library's main goal is to reduce boilerplate code and provide a more concise and expressive way to define type-safe classes compared to plain TypeScript. Key features include: - **Concise class definition**: Define classes with minimal boilerplate code by specifying the constructor function and optional configuration. - **Type-safe initialization**: Ensure that instances are created with the correct types based on the constructor function's return type. - **Flexible property creation**: Define properties and their initial values directly in the constructor function, allowing for custom initialization logic. - **Hidden properties**: Automatically generate getters and setters for specified properties, useful for encapsulation and hiding sensitive data. - **Convenient instance methods**: Access methods like `$clone`, `$update`, `$patch`, and `$data` for easy instance manipulation and data access. - **Structured errors**: Define custom error classes with static or dynamic error messages using the `error` and `staticError` functions. By using `@ayka/strukt`, you can significantly reduce the amount of code needed to create type-safe classes compared to plain TypeScript. The library helps you write cleaner, more maintainable, and expressive code when working with structured data and custom classes. ## Table of Contents - [Overview](#overview) - [Installation](#installation) - [Usage](#usage) - [Basic Usage](#basic-usage) - [Plain TypeScript](#plain-typescript) - [With @ayka/strukt](#with-aykastrukt) - [Structured Errors](#structured-errors) - [Dynamic Error Messages with error](#dynamic-error-messages-with-error) - [Static Error Messages with staticError](#static-error-messages-with-staticerror) - [Strukt Features](#strukt-features) - [Defining Strukt Classes](#defining-strukt-classes) - [Properties and Type Inference](#properties-and-type-inference) - [Constructor Parameters](#constructor-parameters) - [Hidden Properties](#hidden-properties) - [Utility Methods](#utility-methods) - [Type Helpers](#type-helpers) - [Basic Strukt](#basic-strukt) - [Limitations](#limitations) - [No Support for Generics](#no-support-for-generics) - [Property Copying Behavior](#property-copying-behavior) - [Errors](#errors) - [Dynamic Errors with error()](#dynamic-errors-with-error) - [Static Errors with staticError()](#static-errors-with-staticerror) - [Error Features](#error-features) - [Utility Helpers](#utility-helpers) - [selectKeys](#selectkeys) - [lazy Decorator](#lazy-decorator) - [promiseObject](#promiseobject) - [redefineAsAccessors](#redefineasaccessors) - [makeConstructor](#makeconstructor) - [Type Guards](#type-guards) - [Strukt Type Guards](#strukt-type-guards) - [Error Type Guards](#error-type-guards) - [API Documentation](#api-documentation) - [Contributing](#contributing) - [License](#license) ## Installation Install using your preferred package manager: ```bash npm install @ayka/strukt ``` ```bash yarn add @ayka/strukt ``` ```bash pnpm add @ayka/strukt ``` ## Usage ### Basic Usage Let's compare the code needed to create a simple type-safe class in plain TypeScript versus using `@ayka/strukt`. #### Plain TypeScript ```typescript type userParams = { id: number; name: string; email: string; }; class User { id: number; name: string; email: string; createdAt: Date; constructor(data: userParams) { this.id = data.id; this.name = data.name; this.email = data.email; this.createdAt = new Date(); } } const user = new User({ id: 1, name: 'John Doe', email: 'john@example.com', }); ``` #### With `@ayka/strukt` ```typescript import * as Strukt from '@ayka/strukt'; class User extends Strukt.init({ constructor(data: { id: number; name: string; email: string }) { return { ...data, createdAt: new Date(), }; }, }) {} // The User class automatically has properties id, name, email, and createdAt // with types inferred from the constructor return value const user = new User({ id: 1, name: 'John Doe', email: 'john@example.com', }); ``` ### Structured Errors The library provides the `error` and `staticError` functions to help you define custom error classes with meaningful error messages. #### Dynamic Error Messages with `error` Use `Strukt.error()` to define custom error classes with dynamic error messages that include the error data: ```typescript import * as Strukt from '@ayka/strukt'; class InvalidEmailError extends Strukt.error({ constructor(input: { email: string }) { return { data: { email: input.email }, message: `Invalid email address: ${input.email}`, }; }, }) {} function sendEmail(email: string) { if (!isValidEmail(email)) { throw new InvalidEmailError({ email }); } // ... } try { sendEmail('invalid-email'); } catch (err) { if (err instanceof InvalidEmailError) { console.error(err.message); // Output: Invalid email address: invalid-email console.error(err.data); // Output: { email: 'invalid-email' } } } ``` #### Static Error Messages with `staticError` Use `Strukt.staticError()` to define custom error classes with static error messages: ```typescript import * as Strukt from '@ayka/strukt'; class UnauthorizedError extends Strukt.staticError({ message: 'Unauthorized access', }) {} function getSecretData() { if (!isAuthenticated()) { throw new UnauthorizedError(); } // ... } try { getSecretData(); } catch (err) { if (err instanceof UnauthorizedError) { console.error(err.message); // Output: Unauthorized access } } ``` By using structured errors, you can create custom error classes with consistent and informative error messages for your application's specific error cases. ## Strukt Features ### Defining Strukt Classes Strukt classes are defined using the `init()` function, which takes a configuration object with a `constructor` function and an optional `hidden` array. The constructor function defines the shape of the class by returning an object with the desired properties. The property types are inferred from the return value of the constructor. ```typescript import * as Strukt from '@ayka/strukt'; class User extends Strukt.init({ constructor(data: { id: number; name: string; email: string }) { return { ...data, createdAt: new Date(), }; }, }) {} ``` In this example, the `User` class is defined with a constructor that takes an object with `id`, `name`, and `email` properties, and returns an object with those properties plus a `createdAt` property. ### Properties and Type Inference Strukt automatically assigns properties to the class instance based on the return value of the constructor function. The property types are inferred from the constructor return type. ```typescript class User extends Strukt.init({ constructor(data: { id: number; name: string; email: string }) { return { ...data, createdAt: new Date(), age: 25, isActive: true, }; }, }) {} const user = new User({ id: 1, name: 'John Doe', email: 'john@example.com', }); console.log(user.id); // number console.log(user.name); // string console.log(user.email); // string console.log(user.createdAt); // Date console.log(user.age); // number console.log(user.isActive); // boolean ``` In this example, the `User` class has properties `id`, `name`, `email`, `createdAt`, `age`, and `isActive`, with their types inferred from the constructor return value. ### Constructor Parameters Constructor parameters are defined in the constructor function signature. The parameter types are inferred and validated based on the provided arguments when creating an instance of the class. ```typescript class User extends Strukt.init({ constructor(id: number, name: string, email: string) { return { id, name, email, createdAt: new Date(), }; }, }) {} const user = new User(1, 'John Doe', 'john@example.com'); ``` In this example, the `User` class constructor takes `id`, `name`, and `email` parameters, and their types are inferred as `number`, `string`, and `string` respectively. ### Hidden Properties Hidden properties are primarily used to hide sensitive or large data from logging and debugging output. They are still directly accessible from outside the class. To define hidden properties, use the `hidden` option in the `init()` configuration object: ```typescript class User extends Strukt.init({ constructor(data: { id: number; name: string; email: string; secretKey: string; }) { return { ...data, createdAt: new Date(), }; }, hidden: ['secretKey'], }) {} const user = new User({ id: 1, name: 'John Doe', email: 'john@example.com', secretKey: 'abc123', }); console.log(user); // Output: User { id: 1, name: 'John Doe', email: 'john@example.com', secretKey: [Getter/Setter] } console.log(user.secretKey); // Output: 'abc123' ``` In this example, the `secretKey` property is marked as hidden, so it is not included in the console output when logging the instance, but it can still be directly accessed. ### Utility Methods Strukt provides several built-in utility methods for working with instances: - `$clone()`: Creates a new instance with the same data as the original instance. - `$update(data)`: Creates a new instance with the provided data merged with the original instance data. - `$patch(fn)`: Creates a new instance by applying the provided function to the original instance data. The function receives the current data and should return the modified data. - `$data()`: Returns a plain object representation of the instance data. - `$dataKeys()`: Returns an array of the instance's data property names, useful for runtime introspection. These methods create a copy of the instance, promoting immutability, rather than modifying the original instance. ```typescript const user = new User({ id: 1, name: 'John Doe', email: 'john@example.com', }); const clonedUser = user.$clone(); const updatedUser = user.$update({ name: 'Jane Doe' }); const patchedUser = user.$patch((data) => ({ ...data, name: data.name.toUpperCase(), })); const userData = user.$data(); const keys = user.$dataKeys(); // ['id', 'name', 'email', 'createdAt'] ``` In this example, the utility methods are used to create new instances with modified data, and to get a plain object representation of the instance data, without modifying the original `user` instance. ### Type Helpers Strukt instances provide special type helper properties that don't exist at runtime but help with TypeScript type inference and manipulation: - `$$argsType`: Exposes the type of all constructor arguments as a tuple - `$$args1Type`: Exposes the type of the first constructor argument - `$$dataType`: Exposes the type of the instance data (the return type of the constructor) ```typescript class User extends Strukt.init({ constructor(data: { id: number; name: string }, role: string) { return { ...data, role, createdAt: new Date(), }; }, }) {} // Get the type of all constructor arguments type userArgs = User['$$argsType']; // [data: { id: number; name: string }, role: string] // Get the type of first constructor argument type userArgs1 = User['$$args1Type']; // { id: number; name: string } // Get the type of instance data type userData = User['$$dataType']; // { id: number; name: string; role: string; createdAt: Date } ``` ### Basic Strukt When you only need the core data wrapping and hidden properties functionality without the utility methods, you can use `initBasic` to create a lightweight version of a Strukt class: ```typescript import * as Strukt from '@ayka/strukt'; class User extends Strukt.initBasic({ constructor(data: { id: number; name: string }) { return { ...data, createdAt: new Date(), }; }, hidden: ['id'], }) {} const user = new User({ id: 1, name: 'John Doe' }); // Basic Strukt instances only have data properties and hidden property functionality console.log(user.id); // 1 console.log(user.name); // 'John Doe' console.log(user.createdAt); // Date ``` The main differences between `init` and `initBasic` are: - Utility methods (`$clone`, `$update`, `$patch`, `$data`, `$dataKeys`) are not available - Type helper properties (`$$argsType`, `$$args1Type`, `$$dataType`) are not available ### Limitations There are a few limitations to be aware of: #### No Support for Generics Strukt classes do not support generic type parameters. You cannot create a generic Strukt class like this: ```typescript // ❌ This won't work class Container<t> extends Strukt.init({ constructor(value: t) { return { value }; }, }) {} ``` #### Property Copying Behavior When returning data directly in the constructor, all properties from the input object are copied to the instance, including any extra properties not specified in the type: ```typescript // ❌ All properties are copied, including extras class User extends Strukt.init({ constructor(data: { id: number; name: string }) { return data; // Copies all properties from data }, }) {} const user = new User({ id: 1, name: 'John Doe', extra: 'extra' }); user.extra; // 'extra' - extra property is copied even though it's not in the type // ✅ Better: explicitly select properties class User extends Strukt.init({ constructor(data: { id: number; name: string }) { return { id: data.id, name: data.name, }; // Only specified properties are copied }, }) {} const user = new User({ id: 1, name: 'John Doe', extra: 'extra' }); user.extra; // undefined - extra property is not copied ``` ### Errors The library provides two ways to define custom error classes: dynamic errors with `error()` and static errors with `staticError()`. Both extend the built-in `Error` class and provide additional functionality. #### Dynamic Errors with `error()` Dynamic errors allow you to include custom data and generate dynamic error messages based on that data: ```typescript import * as Strukt from '@ayka/strukt'; // Define an error class with custom data class ValidationError extends Strukt.error({ constructor(input: { field: string; value: any }) { return { data: { field: input.field, value: input.value, isValid: false, }, message: `Invalid value for ${input.field}: ${input.value}`, }; }, }) {} // Throw with data throw new ValidationError({ field: 'email', value: 'invalid', }); // Access error data and message try { validate(); } catch (err) { if (err instanceof ValidationError) { console.log(err.message); // "Invalid value for email: invalid" console.log(err.data); // { field: 'email', value: 'invalid', isValid: false } console.log(err.meta); // {} - additional metadata } } ``` #### Static Errors with `staticError()` Static errors are simpler and have a fixed error message: ```typescript import * as Strukt from '@ayka/strukt'; // Define an error with a static message class UnauthorizedError extends Strukt.staticError({ message: 'Unauthorized access', }) {} // Throw with no arguments throw new UnauthorizedError(); // Throw with metadata throw new UnauthorizedError({ userId: 123, cause: new Error('Token expired'), }); // Throw with custom message and metadata throw new UnauthorizedError('Custom unauthorized message', { userId: 123 }); ``` #### Error Features Both types of errors provide: 1. Error Metadata ```typescript // Add metadata when throwing throw new ValidationError( { field: 'email', value: 'invalid' }, { annotation: 'Form validation', cause: new Error('Original error'), }, ); try { validate(); } catch (err) { if (err instanceof ValidationError) { console.log(err.meta.annotation); // "Form validation" console.log(err.cause); // Error: Original error } } ``` 2. Message Override ```typescript // Override message through metadata throw new ValidationError( { field: 'email', value: 'invalid' }, { message: 'Custom error message' }, ); ``` ### Utility Helpers Several utility functions are provided to help with common tasks: #### selectKeys Selects specific keys from an object in a type-safe manner: ```typescript import * as Strukt from '@ayka/strukt'; const obj = { a: 1, b: 2, c: 3 }; const result = Strukt.selectKeys(obj, ['a', 'c']); console.log(result); // { a: 1, c: 3 } // Type-safe: result is typed as { a: number, c: number } ``` #### lazy Decorator A decorator that lazily initializes a property, computing its value only when first accessed: ```typescript import * as Strukt from '@ayka/strukt'; class Example { counter = 0; @Strukt.lazy() get expensiveValue() { this.counter++; return Math.random(); } } const instance = new Example(); console.log(instance.expensiveValue); // Computed first time console.log(instance.expensiveValue); // Same value, not recomputed console.log(instance.counter); // 1 ``` The `lazy` decorator accepts options: ```typescript @Strukt.lazy({ useValue: true, // Store as value instead of getter/setter configurable: true, // Property can be reconfigured enumerable: true, // Property shows up in enumeration writable: true // Value can be changed }) ``` #### promiseObject Converts an object with promise values into a promise of an object with resolved values: ```typescript import * as Strukt from '@ayka/strukt'; const result = await Strukt.promiseObject({ user: fetchUser(123), posts: fetchUserPosts(123), settings: fetchSettings(), }); // result is typed as: // { // user: User, // posts: Post[], // settings: Settings // } console.log(result.user); // Resolved user console.log(result.posts); // Resolved posts console.log(result.settings); // Resolved settings ``` #### redefineAsAccessors Redefines object properties as getters and setters, useful for hiding properties from console output: ```typescript import * as Strukt from '@ayka/strukt'; const obj = { a: 1, b: 2, c: 3 }; Strukt.redefineAsAccessors(obj, ['a', 'b']); console.log(obj); // Output: { a: [Getter/Setter], b: [Getter/Setter], c: 3 } // Properties still work normally obj.a = 10; console.log(obj.a); // 10 ``` #### makeConstructor Creates a function that initializes instances of a class: ```typescript import * as Strukt from '@ayka/strukt'; class Person { constructor( readonly name: string, readonly age: number, ) {} } const createPerson = Strukt.makeConstructor(Person); const john = createPerson('John', 30); // john is instance of Person with correct types ``` ### Type Guards Type guards are provided to check the type of values at runtime: #### Strukt Type Guards ```typescript import * as Strukt from '@ayka/strukt'; // Check if a value is a Strukt instance (created with init()) if (Strukt.isStrukt(value)) { // value has utility methods like $clone, $update, etc. const clone = value.$clone(); } // Check if a value is any Strukt instance (created with init() or initBasic()) if (Strukt.isBasicStrukt(value)) { // value has basic Strukt functionality console.log(value); // Hidden properties are shown as [Getter/Setter] } ``` #### Error Type Guards ```typescript import * as Strukt from '@ayka/strukt'; // Check if a value is any Strukt error if (Strukt.isErrorStrukt(error)) { console.log(error.meta); // Access error metadata } // Check if a value is a static error if (Strukt.isStaticErrorStrukt(error)) { // error was created with staticError() console.log(error.message); // Static message } // Check if a value is a dynamic error if (Strukt.isDynamicErrorStrukt(error)) { // error was created with error() console.log(error.data); // Access error data } ``` ### API Documentation For detailed API documentation, including all exported functions, types, and interfaces, see the [API Documentation](docs/globals.md). ## Contributing Contributions are welcome! If you find any issues or have suggestions for improvements, please open an issue or submit a pull request. ## License This project is licensed under the MIT License.