diff --git a/.drone.jsonnet b/.drone.jsonnet new file mode 100644 index 0000000..fefec26 --- /dev/null +++ b/.drone.jsonnet @@ -0,0 +1,110 @@ +local bun_image = "oven/bun:1"; +local node_image = "node:20"; + + +local install_step = { + name: "install", + image: bun_image, + commands: ["bun install --frozen-lockfile"], +}; + +local lint_step = { + name: "lint", + image: bun_image, + commands: ["bun lint:tsc"], +}; + +local build_step = { + name: "build", + image: bun_image, + commands: ["bun run build"], +}; + +local pack_step = { + name: "pack", + image: node_image, + commands: ["npm pack --dry-run"], +}; + +local publish_step = { + name: "publish", + image: node_image, + + environment: { + NPM_TOKEN: { from_secret: "npm_token" } + }, + + commands: [ + "npm set @thilawyn:registry https://git.jvalver.de/api/packages/thilawyn/npm/", + "npm config set -- //git.jvalver.de/api/packages/thilawyn/npm/:_authToken $NPM_TOKEN", + "npm publish", + ], +}; + + +[ + // Lint the whole project when not in master, not in a PR nor on a tag + { + kind: "pipeline", + type: "docker", + name: "lint", + + trigger: { + ref: { + exclude: [ + "refs/heads/master", + "refs/pull/**", + "refs/tags/**", + ] + } + }, + + steps: [ + install_step, + lint_step, + ], + }, + + // Build the package without publishing for pull requests + { + kind: "pipeline", + type: "docker", + name: "build", + + trigger: { + ref: { + include: ["refs/pull/**"] + } + }, + + steps: [ + install_step, + lint_step, + build_step, + pack_step, + ], + }, + + // Build and publish the package for master and tags + { + kind: "pipeline", + type: "docker", + name: "build-publish", + + trigger: { + ref: { + include: [ + "refs/heads/master", + "refs/tags/**", + ] + } + }, + + steps: [ + install_step, + lint_step, + build_step, + publish_step, + ], + }, +] diff --git a/.gitignore b/.gitignore index ceaea36..745264f 100644 --- a/.gitignore +++ b/.gitignore @@ -129,4 +129,3 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* - diff --git a/LICENSE b/LICENSE index fe6b51e..46959f5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Thilawyn +Copyright (c) 2023 Thilawyn Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..ebdb9cc Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..e7b1a5b --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "@thilawyn/thilatrait", + "version": "0.1.2", + "type": "module", + "publishConfig": { + "registry": "https://git.jvalver.de/api/packages/thilawyn/npm/" + }, + "files": [ + "./dist" + ], + "exports": { + ".": { + "import": { + "types": "./dist/lib.d.mts", + "default": "./dist/lib.mjs" + }, + "require": { + "types": "./dist/lib.d.cts", + "default": "./dist/lib.cjs" + } + } + }, + "scripts": { + "build": "rollup -c rollup.config.ts", + "lint:tsc": "tsc --noEmit", + "clean:cache": "rm -f tsconfig.tsbuildinfo", + "clean:dist": "rm -rf dist", + "clean:node": "rm -rf node_modules" + }, + "dependencies": { + "type-fest": "^4.9.0" + }, + "devDependencies": { + "@rollup/plugin-node-resolve": "^15.2.3", + "bun-types": "latest", + "npm-check-updates": "^16.14.12", + "npm-sort": "^0.0.4", + "rollup": "^4.9.4", + "rollup-plugin-cleanup": "^3.2.1", + "rollup-plugin-ts": "^3.4.5", + "tsx": "^4.7.0", + "typescript": "^5.3.3" + } +} diff --git a/rollup.config.ts b/rollup.config.ts new file mode 100644 index 0000000..bca2cda --- /dev/null +++ b/rollup.config.ts @@ -0,0 +1,34 @@ +import { nodeResolve } from "@rollup/plugin-node-resolve" +import { defineConfig } from "rollup" +import cleanup from "rollup-plugin-cleanup" +import ts from "rollup-plugin-ts" +import pkg from "./package.json" assert { type: "json" } + + +export default defineConfig({ + input: "src/index.ts", + + output: [ + { + file: pkg.exports["."].import.default, + format: "esm", + }, + + { + file: pkg.exports["."].require.default, + format: "cjs", + }, + ], + + external: id => !/^[./]/.test(id), + + plugins: [ + nodeResolve(), + ts(), + + cleanup({ + comments: "jsdoc", + extensions: ["ts"], + }), + ], +}) diff --git a/src/expresses.ts b/src/expresses.ts new file mode 100644 index 0000000..5784e56 --- /dev/null +++ b/src/expresses.ts @@ -0,0 +1,84 @@ +import { AbstractClass, Opaque } from "type-fest" +import { Trait, TraitApplierSuperTag } from "." +import { ClassesInstances, ClassesStaticMembers, MergeInheritanceTree, MergeInheritanceTreeWithoutOverriding, StaticMembers, TraitsClasses } from "./util" + + +/** + * Extends a class with the given traits and expresses their combined functionality. + * @template C - The abstract class type. + * @template Traits - An array of traits. + * @param extend - The class to extend. + * @param traits - An array of traits to apply. + * @returns A new class type expressing the combined functionality of the base class and traits. + * @example + * Extends a superclass and applies traits: + * ```ts + * class User extends extendsAndExpresses(Entity, + * Identifiable(), + * Permissible, + * ) { + * readonly id: bigint + * + * constructor(id: bigint) { + * super() + * this.id = id + * } + * } + * ``` + */ +export function extendsAndExpresses< + C extends AbstractClass, + Traits extends readonly Trait[], +>( + extend: C, + ...traits: Traits +) { + return traits.reduce( + (previous, trait) => trait(previous), + extend as Opaque, + ) as unknown as ( + AbstractClass< + MergeInheritanceTreeWithoutOverriding<[ + InstanceType, + ...ClassesInstances< + TraitsClasses + >, + ]>, + + ConstructorParameters + > & + + MergeInheritanceTree<[ + StaticMembers, + ...ClassesStaticMembers< + TraitsClasses + >, + ]> + ) +} + +/** + * Expresses the combined functionality of multiple traits. + * @template Traits - An array of trait. + * @param traits - An array of trait to apply. + * @returns A new class type expressing the combined functionality of the traits. + * @example + * Applies traits to a class: + * ```ts + * class User extends expresses(Identifiable(), Permissible) { + * readonly id: bigint + * + * constructor(id: bigint) { + * super() + * this.id = id + * } + * } + * ``` + */ +export function expresses< + Traits extends readonly Trait[], +>( + ...traits: Traits +) { + return extendsAndExpresses(Object, ...traits) +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ae86066 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +export * from "./expresses" +export * from "./trait" diff --git a/src/tests.ts b/src/tests.ts new file mode 100644 index 0000000..cff84f8 --- /dev/null +++ b/src/tests.ts @@ -0,0 +1,131 @@ +import { AbstractClass } from "type-fest" +import { expresses, extendsAndExpresses, trait } from "." +import { ClassesInstances, MergeInheritanceTree } from "./util" + + +const Identifiable = () => + trait(Super => { + abstract class Identifiable extends Super { + abstract readonly id: ID + + equals(el: Identifiable) { + return this.id === el.id + } + + constructor(...args: any[]) { + super(...args) + console.log("Identified constructor") + } + } + + return Identifiable + }) + +const ImplementsIdentifiable = (defaultID: ID) => + trait(Super => { + abstract class ImplementsIdentifiable extends extendsAndExpresses( + Super, + Identifiable(), + ) { + id: ID = defaultID + + constructor(...args: any[]) { + super(...args) + console.log("ImplementsIdentifiable constructor") + } + } + + return ImplementsIdentifiable + }) + + +const Permissible = trait(Super => { + abstract class Permissible extends Super { + static readonly defaultPermissions: string[] = [] + permissions: string[] = [] + + constructor(...args: any[]) { + super(...args) + console.log("Permissible constructor") + } + } + + return Permissible +}) + + +const UserProto = expresses( + // Identifiable(), + ImplementsIdentifiable(0n), + Permissible, +) + +class User extends UserProto { + constructor(id: bigint) { + super() + this.id = id + } +} + +const user1 = new User(1n) +console.log(user1) +console.log(user1.equals(user1)) + + +const Test1 = trait(Super => { + abstract class Test1 extends Super { + declare static name: string + declare static testValue: ( + { _tag: "type1", value: string } | + { _tag: "type2", value: number } + ) + + abstract name: string + + declare createdAt: Date + declare readonly status: ( + { _tag: "awaitingPayment" } | + { _tag: "active", activeSince: Date, expiresAt?: Date } | + { _tag: "expired", expiredSince: Date } + ) + } + + return Test1 +}) + +const Test2 = trait(Super => { + abstract class Test2 extends Super { + declare readonly status: { _tag: "active", activeSince: Date, expiresAt?: Date } + } + + return Test2 +}) + +const Test3 = trait(Super => { + abstract class Test3 extends Super { + declare static testValue: { _tag: "type2", value: number } + declare lol: 10n + } + + return Test3 +}) + +const TestObjectProto = expresses(Test1, Test2, Test3) +TestObjectProto.testValue + + +interface Gneugneu { + ahi: string + get adolf(): string +} + +class GneugneuImpl implements Gneugneu { + ahi: string = "" + get adolf(): string { + throw new Error("Method not implemented.") + } + +} + +abstract class Issou extends GneugneuImpl implements Gneugneu { +} diff --git a/src/trait.ts b/src/trait.ts new file mode 100644 index 0000000..21fd00c --- /dev/null +++ b/src/trait.ts @@ -0,0 +1,117 @@ +import { AbstractClass, AbstractConstructor, Opaque } from "type-fest" + + +/** + * Represents a trait that can be applied to a class. + * @template C - The abstract class type. + */ +export type Trait< + C extends AbstractClass +> = Opaque< + TraitApplier, + "@thilawyn/thilatrait/Trait" +> + +export type TraitApplierSuperTag = "@thilawyn/thilatrait/Super" + +/** + * Represents the function signature for applying a trait to a parent class. + * @template C - The abstract class type. + */ +export type TraitApplier< + C extends AbstractClass +> = ( + (Super: Opaque, TraitApplierSuperTag>) => Opaque +) + +/** + * Creates a trait using the provided trait applier function. + * @template C - The abstract class type. + * @param applier - The trait applier function. + * @returns A trait. + * @example + * Creates a trait: + * ```ts + * const Permissible = trait(Super => { + * abstract class Permissible extends Super { + * static readonly defaultPermissions: string[] = [] + * permissions: string[] = [] + * + * // Constructor is optional + * // If you wish to use it, make sure it takes any[] as an args array and passes it to the super call. This is necessary for inheritance to work properly. + * // Trait constructors cannot have typed arguments of their own, they only serve to run logic during object instantiation. + * constructor(...args: any[]) { + * super(...args) + * } + * } + * + * return Permissible + * }) + * ``` + * Creates a generic trait: + * ```ts + * const Identifiable = () => + * trait(Super => { + * abstract class Identifiable extends Super { + * abstract readonly id: ID + * + * equals(el: Identifiable) { + * return this.id === el.id + * } + * + * // Optional + * constructor(...args: any[]) { + * super(...args) + * } + * } + * + * return Identifiable + * }) + * ``` + * Creates a subtrait: + * ```ts + * const ImplementsIdentifiable = (defaultID: ID) => + * trait(Super => { + * abstract class ImplementsIdentifiable extends extendsAndExpresses( + * Super, + * Identifiable(), + * ) { + * id: ID = defaultID + * + * // Optional + * constructor(...args: any[]) { + * super(...args) + * } + * } + * + * return ImplementsIdentifiable + * }) + * ``` + */ +export function trait< + C extends AbstractClass +>( + applier: TraitApplier +) { + return applier as Trait +} + +/** + * Returns the class type of a trait. + * @template T - The trait type. + */ +export type TraitClass = ( + T extends Trait + ? C + : never +) + +/** + * Returns the instance type of a trait. + * @template T - The trait type. + */ +export type TraitInstance = ( + T extends Trait + ? InstanceType + : never +) diff --git a/src/util/class.ts b/src/util/class.ts new file mode 100644 index 0000000..325aba5 --- /dev/null +++ b/src/util/class.ts @@ -0,0 +1,66 @@ +import { AbstractClass } from "type-fest" + + +/** + * Represents an array of instances corresponding to the provided classes. + * @template Classes - An array of classes extending AbstractClass. + */ +export type ClassesInstances[]> = ( + Classes extends [infer Class, ...infer Rest] + ? Class extends AbstractClass + ? Rest extends AbstractClass[] + ? [InstanceType, ...ClassesInstances] + : never + : never + : [] +) + +/** + * Represents an intersection of instances of the provided classes. + * @template Classes - An array of classes extending AbstractClass. + */ +export type ClassesInstancesIntersection[]> = ( + Classes extends [infer Class, ...infer Rest] + ? Class extends AbstractClass + ? Rest extends AbstractClass[] + ? InstanceType & ClassesInstancesIntersection + : never + : never + : {} +) + +/** + * Represents the static members of a class. + * @template Class - A class extending AbstractClass. + */ +export type StaticMembers> = ( + Omit +) + +/** + * Represents an array of static members corresponding to the provided classes. + * @template Classes - An array of classes extending AbstractClass. + */ +export type ClassesStaticMembers[]> = ( + Classes extends [infer Class, ...infer Rest] + ? Class extends AbstractClass + ? Rest extends AbstractClass[] + ? [StaticMembers, ...ClassesStaticMembers] + : never + : never + : [] +) + +/** + * Represents an intersection of static members of the provided classes. + * @template Classes - An array of classes extending AbstractClass. + */ +export type ClassesStaticMembersIntersection[]> = ( + Classes extends [infer Class, ...infer Rest] + ? Class extends AbstractClass + ? Rest extends AbstractClass[] + ? StaticMembers & ClassesStaticMembersIntersection + : never + : never + : {} +) diff --git a/src/util/index.ts b/src/util/index.ts new file mode 100644 index 0000000..c5faea6 --- /dev/null +++ b/src/util/index.ts @@ -0,0 +1,3 @@ +export * from "./class" +export * from "./inheritance" +export * from "./trait" diff --git a/src/util/inheritance.ts b/src/util/inheritance.ts new file mode 100644 index 0000000..a179d78 --- /dev/null +++ b/src/util/inheritance.ts @@ -0,0 +1,40 @@ +/** + * Represents the common keys between two types. + * @template A - The first type. + * @template B - The second type. + */ +export type CommonKeys = Extract + +/** + * Merges an inheritance tree defined by an array of types, considering overrides. + * @template T - An array of types representing the inheritance tree. + */ +export type MergeInheritanceTree = ( + T extends [infer Super, infer Self, ...infer Rest] + ? Pick> extends Pick> + ? MergeInheritanceTree<[ + Omit> & Self, + ...Rest, + ]> + : never + : T extends [infer Self] + ? Self + : void +) + +/** + * Merges an inheritance tree defined by an array of types without allowing overrides. + * @template T - An array of types representing the inheritance tree. + */ +export type MergeInheritanceTreeWithoutOverriding = ( + T extends [infer Super, infer Self, ...infer Rest] + ? Pick> extends Pick> + ? MergeInheritanceTreeWithoutOverriding<[ + Super & Self, + ...Rest, + ]> + : never + : T extends [infer Self] + ? Self + : void +) diff --git a/src/util/trait.ts b/src/util/trait.ts new file mode 100644 index 0000000..149599d --- /dev/null +++ b/src/util/trait.ts @@ -0,0 +1,16 @@ +import { Trait, TraitClass } from ".." + + +/** + * Represents an array of classes corresponding to the provided traits. + * @template Traits - An array of traits extending Trait. + */ +export type TraitsClasses[]> = ( + Traits extends [infer T, ...infer Rest] + ? T extends Trait + ? Rest extends Trait[] + ? [TraitClass, ...TraitsClasses] + : never + : never + : [] +) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5470793 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "moduleDetection": "force", + // "allowImportingTsExtensions": true, + // "noEmit": true, + "declaration": true, + "composite": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "types": [ + "bun-types" + ] + }, + "include": ["src"] +}