@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
Markdown
# @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.