Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial draft of the type system API #1

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
root = true

[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
insert_final_newline = true
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.js
75 changes: 75 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"ecmaVersion": 2017,
"sourceType": "module"
},
"plugins": [
"@typescript-eslint",
"header"
],
"ignorePatterns": [
"**/{node_modules,lib,bin}"
],
"rules": {
// List of [ESLint rules](https://eslint.org/docs/rules/)
"arrow-parens": ["off", "as-needed"], // do not force arrow function parentheses
"constructor-super": "error", // checks the correct use of super() in sub-classes
"dot-notation": "error", // obj.a instead of obj['a'] when possible
"eqeqeq": "error", // ban '==', don't use 'smart' option!
"guard-for-in": "error", // needs obj.hasOwnProperty(key) checks
"new-parens": "error", // new Error() instead of new Error
"no-bitwise": "error", // bitwise operators &, | can be confused with &&, ||
"no-caller": "error", // ECMAScript deprecated arguments.caller and arguments.callee
"no-cond-assign": "error", // assignments if (a = '1') are error-prone
"no-debugger": "error", // disallow debugger; statements
"no-eval": "error", // eval is considered unsafe
"no-inner-declarations": "off", // we need to have 'namespace' functions when using TS 'export ='
"no-labels": "error", // GOTO is only used in BASIC ;)
"no-multiple-empty-lines": ["error", {"max": 1}], // two or more empty lines need to be fused to one
"no-new-wrappers": "error", // there is no reason to wrap primitve values
"no-throw-literal": "error", // only throw Error but no objects {}
"no-trailing-spaces": "error", // trim end of lines
"no-unsafe-finally": "error", // safe try/catch/finally behavior
"no-var": "error", // use const and let instead of var
"space-before-function-paren": ["error", { // space in function decl: f() vs async () => {}
"anonymous": "never",
"asyncArrow": "always",
"named": "never"
}],
"semi": [2, "always"], // Always use semicolons at end of statement
"quotes": [2, "single", { "avoidEscape": true }], // Prefer single quotes
"use-isnan": "error", // isNaN(i) Number.isNaN(i) instead of i === NaN
"header/header": [ // Use MIT/Generated file header
2,
"block",
{ "pattern": "MIT License|DO NOT EDIT MANUALLY!" }
],
// List of [@typescript-eslint rules](https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#supported-rules)
"@typescript-eslint/adjacent-overload-signatures": "error", // grouping same method names
"@typescript-eslint/array-type": ["error", { // string[] instead of Array<string>
"default": "array-simple"
}],
"@typescript-eslint/ban-types": "error", // bans types like String in favor of string
"@typescript-eslint/no-inferrable-types": "off", // don't blame decls like "index: number = 0", esp. in api signatures!
"@typescript-eslint/indent": "error", // consistent indentation
"@typescript-eslint/no-explicit-any": "error", // don't use :any type
"@typescript-eslint/no-misused-new": "error", // no constructors for interfaces or new for classes
"@typescript-eslint/no-namespace": "off", // disallow the use of custom TypeScript modules and namespaces
"@typescript-eslint/no-non-null-assertion": "off", // allow ! operator
"@typescript-eslint/no-parameter-properties": "error", // no property definitions in class constructors
"@typescript-eslint/no-unused-vars": ["error", { // disallow Unused Variables
"argsIgnorePattern": "^_"
}],
"@typescript-eslint/no-var-requires": "error", // use import instead of require
"@typescript-eslint/prefer-for-of": "error", // prefer for-of loop over arrays
"@typescript-eslint/prefer-namespace-keyword": "error", // prefer namespace over module in TypeScript
"@typescript-eslint/triple-slash-reference": "error", // ban /// <reference />, prefer imports
"@typescript-eslint/type-annotation-spacing": "error" // consistent space around colon ':'
}
}
39 changes: 39 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"name": "typir-workspace",
"private": true,
"engineStrict": true,
"engines": {
"npm": ">= 7.7.0"
},
"scripts": {
"clean": "shx rm -rf packages/**/lib packages/**/out packages/**/*.tsbuildinfo",
"build": "tsc -b tsconfig.build.json",
"watch": "tsc -b tsconfig.build.json -w",
"build:clean": "npm run clean && npm run build",
"lint": "npm run lint --workspaces",
"test": "vitest",
"test-ui": "vitest --ui",
"coverage": "vitest run --coverage"
},
"author": "TypeFox GmbH",
"license": "MIT",
"devDependencies": {
"@types/node": "~16.18.11",
"@types/vscode": "~1.67.0",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.51.0",
"@vitest/coverage-c8": "~0.28.4",
"@vitest/ui": "~0.28.4",
"concurrently": "^7.6.0",
"eslint": "^8.33.0",
"eslint-plugin-header": "^3.1.1",
"editorconfig": "~1.0.2",
"shx": "^0.3.4",
"typescript": "~4.9.5",
"vitest": "~0.28.4"
},
"workspaces": [
"packages/*",
"examples/*"
]
}
42 changes: 42 additions & 0 deletions packages/typir/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"name": "typir",
"version": "0.0.1",
"description": "General purpose type checking library",
"homepage": "https://langium.org",
"engines": {
"node": ">=14.0.0"
},
"keywords": [
"typesystem",
"typescript"
],
"license": "MIT",
"files": [
"lib",
"src"
],
"main": "lib/index.js",
"types": "lib/index.d.ts",
"scripts": {
"clean": "shx rm -rf lib coverage",
"build": "tsc",
"watch": "tsc --watch",
"lint": "eslint src test --ext .ts",
"publish:next": "npm --no-git-tag-version version \"$(semver $npm_package_version -i minor)-next.$(git rev-parse --short HEAD)\" && npm publish --tag next",
"publish:latest": "npm publish --tag latest"
},
"volta": {
"node": "16.19.0",
"npm": "8.19.3"
},
"repository": {
"type": "git",
"url": "https://github.com/langium/langium",
"directory": "packages/langium"
},
"bugs": "https://github.com/langium/langium/issues",
"author": {
"name": "TypeFox",
"url": "https://www.typefox.io"
}
}
16 changes: 16 additions & 0 deletions packages/typir/src/assignablity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Type } from "./base";

export interface AssignabilityResult {
/**
* The failure of this result. If `undefined`, the assignability check succeeded.
*/
readonly failure?: AssignabilityFailure;
}

export interface AssignabilityFailure {
from: string;
to: string;
Comment on lines +11 to +12
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If types can be printed to strings already, why not provide the type itself here in the failure? This allows the dev to still print the type (assuming all types will have this behavior, based on the requirements for the library) whilst still being able to use it for other purposes if desired.

nested?: AssignabilityFailure;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking of what the dev may expect from an assignability result, it may be unexpected to receive a nested result. If this is desired, would it instead make sense to provide an upfront Array? This might make it clearer to iterate over as well for inspection, rather than having to repeatedly extract & check the next nested entry.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. If the design of Typir is not similar to the needs of Langium, than we should provide an easy wrapper/transformer for Langium instead.

}

export type AssignabilityCallback<From extends Type<unknown>, To extends Type<unknown> = From> = (types: { from: From, to: To }) => AssignabilityResult;
22 changes: 22 additions & 0 deletions packages/typir/src/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { TypeSystem } from "./type-system";
import { Disposable } from "./utils";

export interface Type<T> {
readonly literal?: T;
readonly members: Iterable<TypeMember<T>>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This allows adding multiple members that share the same name. It might be easier to instead go with a map/record.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But the order of members is important, isn't it?

/**
* A reference to the original type system that produced this type
*/
readonly typeSystem: TypeSystem<T>;
}

export interface TypeMember<T> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Members appear to be annotated types, with name & optional in addition. This could extend Type, and supplement with additional properties to give it the same look & feel.

name?: string;
literal?: T;
optional: boolean;
type: Type<T>;
}

export interface MemberCollection<T> extends Iterable<TypeMember<T>> {
push(...member: TypeMember<T>[]): Disposable;
}
29 changes: 29 additions & 0 deletions packages/typir/src/function-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { MemberCollection, Type, TypeMember } from "./base";
import { TypeParameter } from "./type-parameter";

export interface FunctionType<T> extends Type<T> {
readonly name?: string;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worthwhile to consider making it so any type can have a name. A point to consider is how do you reference a type without a name? You can write out the form again, but allowing any type to have a name will probably go a long way later for helping with inference (where types need to be looked up while checking)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we need to distinguish a human-readable type for error messages and a "technical" name for referencing types?

readonly members: MemberCollection<T>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems parameter types will have members, as will the return type, but does the function itself need members as well? If so what would they be, and how would they help?

readonly typeParameters: TypeParameter<T>[];
readonly typeArguments: Type<T>[];
applyTypeArguments(args: Type<T>[]): FunctionType<T>;
Comment on lines +8 to +9
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be nice to have at the level of all types, and not just functions. This would go along with the notion that a type may or may not be generic, but is still a type. The API could be retained like so, but it may help to then be able to invoke the same constraints on the param & return types.

readonly parameters: FunctionParameter<T>;
readonly returnType: Type<T>[];
}

export interface FunctionTypeOptions<T> {
name?: string;
literal?: T;
members?: TypeMember<T>[];
typeParameters?: TypeParameter<T>[];
parameters?: FunctionParameter<T>[];
returnType?: Type<T>[];
}

export interface FunctionParameter<T> {
readonly name?: string
readonly literal?: T;
readonly type: Type<T>;
readonly optional: boolean;
readonly spread: boolean;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this feels like a specialized case of a function parameter. Internally this also places a constraint on the kinds of types this parameter can match with, feels like defining a typing rule before actually writing one. Not necessarily bad, but it may make it difficult to reason about typing rules/relationships later.

}
Empty file added packages/typir/src/index.ts
Empty file.
13 changes: 13 additions & 0 deletions packages/typir/src/indexer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Type } from "./base";

export interface Indexer<T> extends Type<T> {
readonly: boolean;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

readonly && writeonly? Maybe use an enum/string union

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would agree as well.

writeonly: boolean;
parameters: IndexerParameter<T>[];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If types were named, then this parameter could be just Type<T>, making it a bit simpler to work with.

}

export interface IndexerParameter<T> {
readonly name?: string
readonly literal?: T;
readonly type: Type<T>;
}
15 changes: 15 additions & 0 deletions packages/typir/src/is.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { PrimitiveType } from "./primitive";

export const Primitive = Symbol('Primitive');

export function isPrimitiveType<T>(type: unknown): type is PrimitiveType<T> {
return isType(type, Primitive);
}

function isType(type: unknown, symbol: symbol): boolean {
if (typeof type !== 'object' || !type) {
return false;
}
const value = type as { '_type': symbol };
return value._type === symbol;
}
26 changes: 26 additions & 0 deletions packages/typir/src/primitive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { AssignabilityCallback } from "./assignablity";
import { MemberCollection, Type, TypeMember } from "./base";
import { Disposable } from "./utils";

export interface PrimitiveType<T> extends Type<T> {
readonly name: string
readonly members: MemberCollection<T>;
Comment on lines +6 to +7
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Name might be good to include on all types (as mentioned before), and members is already present on Type, but in a slightly different form; can be dropped here.

constant(options: PrimitiveTypeConstantOptions<T>): PrimitiveTypeConstant<T>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, interesting, so does this imply types are capable of being modified in this system? If so, we're working with open types, which is more like interfaces than types, and will greatly complicate reasoning about them.

assignable(to: PrimitiveType<T>): Disposable;
assignable(callback: AssignabilityCallback<PrimitiveType<T>>): Disposable;
Comment on lines +9 to +10
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks nice from API perspective, but I'm curious how this would be implemented internally. Is the plan to have relationships for types stored on types, or higher up within the system itself. The latter would make it much easier to get information about all relationships/rules.

}

export interface PrimitiveTypeConstant<T> extends Type<T> {
type: PrimitiveType<T>
value: unknown;
}

export interface PrimitiveTypeConstantOptions<T> {
value: unknown;
literal?: T;
}

export type PrimitiveTypeOptions<T> = string | {
name: string
members: TypeMember<T>[]
}
15 changes: 15 additions & 0 deletions packages/typir/src/tuple-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Type } from "./base";

export interface TupleType<T> extends Type<T> {
types: Type<T>[];
/**
* Indicates that the last type in this tuple is spread.
*/
spread: boolean;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A thought, do we really want to support the spread operator? I'm sure the case comes up, but is it often enough to really need to support it directly, rather than having it be implemented by devs when needed themselves. How often does this come up in languages that are expressed in Langium?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to see support for the spread operator by Typir in general, but I am not sure, how to integrate it. Maybe as an add-on?

}

export interface TupleTypeOptions<T> {
literal?: T;
types: Type<T>[];
spread?: boolean;
}
37 changes: 37 additions & 0 deletions packages/typir/src/type-category.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { AssignabilityCallback } from "./assignablity";
import { MemberCollection, Type, TypeMember } from "./base";
import { TypeParameter } from "./type-parameter";
import type { TypeSystem } from "./type-system";
import { Disposable } from "./utils";

export interface TypeCategory<T> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little confused by the usage of a 'category' of things, as this is very general.

If this is being used to express classes, structs, and interfaces, why not provide a more direct representation to express classes, structs, and interfaces directly? Unless there's a concrete motivation to making a general type category, I would lean on keeping it simple and targeting known cases first. If it turns out we need something more general, we can always add it in later.

This would also be good considering the audience, which is likely not so versed in type theory, or type systems in general. Keeping to familiar terms & type forms will help users to leverage this library as intended, and effectively.

readonly name: string;
readonly typeSystem: TypeSystem<T>;
create(options: TypeCategoryInstanceOptions<T>): TypeCategoryInstance<T>;
assignable(to: TypeCategory<T>, callback: AssignabilityCallback<TypeCategoryInstance<T>>): Disposable;
castable(to: TypeCategory<T>, callback: AssignabilityCallback<TypeCategoryInstance<T>>): Disposable;
}

export interface TypeCategoryInstance<T> extends Type<T>, Disposable {
readonly name?: string;
readonly category: TypeCategory<T>;
readonly members: MemberCollection<T>;
readonly super: TypeCategoryInstance<T>[];
readonly typeParameters: TypeParameter<T>[];
readonly typeArguments: Type<T>[];
applyTypeArguments(args: Type<T>[]): TypeCategoryInstance<T>;
assignable(callback: AssignabilityCallback<Type<T>>): Disposable;
castable(callback: AssignabilityCallback<Type<T>>): Disposable;
}

export interface TypeCategoryOptions {
name: string
}

export interface TypeCategoryInstanceOptions<T> {
name?: string
literal?: T
parameters?: TypeParameter<T>[];
members?: TypeMember<T>[];
typeParameters: TypeParameter<T>[];
}
16 changes: 16 additions & 0 deletions packages/typir/src/type-function.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Type } from "./base";
import { TypeParameter } from "./type-parameter";

export interface TypeFunction<T> extends Type<T> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does a TypeFunction need to extend a type? In addition, does it need to contain a type? This suggests we will be reasoning about functions that operate on types as types themselves.

What this seems like is a simple form of a type rule, where given appropriate parameter types, a resultant type is constructed. If this is the case, a renaming to something like TypeRule would be more appropriate. In addition, type may not be needed, unless it denotes the 'produced' type. There's also the question of whether a given type function needs to produce many types (like as is the case with the spread operator).

readonly name: string;
readonly parameters: TypeParameter<T>[];
readonly type: Type<T>;
applyArguments(args: Type<T>[]): Type<T>;
}

export interface TypeFunctionOptions<T> {
readonly name: string;
readonly literal?: T;
readonly parameters?: TypeParameter<T>[];
readonly type: Type<T>;
}
10 changes: 10 additions & 0 deletions packages/typir/src/type-intersection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Type } from "./base";

export interface TypeIntersection<T> extends Type<T> {
types: Type<T>[];
}

export interface TypeIntersectionOptions<T> {
literal?: T;
types: Type<T>[];
}
Loading