File: README.md
Navi
Set-theory based type system used in chaiNNer
Documentation for the Navi language can be found here.
File: LICENSE.md
MIT License
Copyright (c) 2022 Michael Schmidt
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:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File: navi.md
Navi
Navi is type system, but also a programming language.
This document will explain basics of how Navi works.
What are types?
Conceptually, types are sets of their possible value.
E.g. the type string is the set of all possible strings (e.g. "", "foo"), and the type number is the set of all numbers (e.g. 0, 1, -2, 3.14).
Since types are sets, set operations are as usual.
In particular, we denote set intersection as A & B, and set union as A | B, where A and B are types.
Set difference is also supported as without(A, B), but with some limitation, more on that later.
However, types cannot be arbitrary sets.
E.g. the set of all prime numbers cannot be expressed in Navi.
Navi provides primitive types from which more types can be constructed. All sets that can be constructed from finitely many primitives are types.
Why is this a programming language?
Many programming languages (e.g. C++, Rust, TypeScript) have turing-complete type systems. However, while these type systems are powerful, we typically don't think of them as programming languages.
Navi is different. Navi doesn't hide the fact that type systems are programming languages. It embraces it. So Navi's syntax is designed to be as close to a programming language as possible. E.g. here's how you define a type definition in Navi:
struct Size {
width: uint,
height: uint,
}This would look pretty familiar to anyone who has programmed in Rust before. Here's how you define functions:
def area(size: Size): uint {
size.width * size.height
}
def diagonal(size: Size): number {
let x = size.width;
let y = size.height;
number::sqrt(x ** 2 + y ** 2)
}Note that the last expression is the return value if the function. Aside from that, this should look pretty familiar again. This is how you would write this function in Rust, TypeScript, and many other languages, but it's done entirely within a type system.
Here's a more complex example to show that we are actually working in a type system:
def scale(size: Size, factor: 0..): Size {
Size {
width: uint & round(size.width * factor),
height: uint & round(size.height * factor),
}
}Two things to note:
0..: This is the interval type containing all numbers greater than or equal to 0. This means thatfactoraccepts values such as 0, 1, 2, 3.14, and inf.uint & round(...): This is a type intersection or type cast. This is needed, because the return type ofround(size.width * factor)is actuallyuint | nan(because0 * inf == nan). Sincenanisn't an unsigned integer, it's not assignable toSize.width: uint, so Navi actually gives us a type error when we try to writeSize { width: round(size.width * factor), ... }. To fix this, we need to cast the return type ofround(size.width * factor)touintusing the&operator.
As we can see, Navi is really strict about types, so we sometimes need to employ tricks (like type casts) to get the type system to accept our code.
Primitive types
`any`
This type represents the set containing all values.
`never`
This type represents the empty set.
Within Navi, never generally represents a dead end or an error.
Assumptions about input types are typically represented using intersection.
E.g. "the result is an integer" would be expressed as int & result.
So if the assumption is not met, never appears.
`number`
This type represents any real number plus Β±infinity and NaN.
Navi uses 64bit floating values to represent numbers internally.
Numeric literal types
Numeric literal types (e.g. 2, 3, 3.1415) are types that represent their numeric value.
E.g. 2 (the type) is equal to the set that contains the number 2.
Interval types
Interval types (e.g. 0..4, 0.25..3.14, -inf..inf, 0..inf) are types that represent all numbers between and including their ends.
E.g. 0..4 includes all number 0 to 4 including 0 and 4.
Note that -inf and inf can be omitted. E.g. 0..inf = 0.. and -inf..inf = ...
Integer interval types
Integer interval types (e.g. int(0..4), int(-inf..inf), int(0..inf)) are types that represent all integer numbers between and including their integer ends.
E.g. int(0..4) includes 0, 1, 2, 3, and 4.
Importantly, the infinities are not integers, so e.g. int(-inf..inf) does not include infinity.
Same as with intervals, -inf and inf can be omitted. E.g. int(0..inf) = int(0..) and int(-inf..inf) = int(..).
`string`
This type represents any string.
String literal types
String literal types (e.g. "", "foo") are types that represent their string value.
E.g. "foo" (the type) is equal to the set that contain the string "foo".
String sets and inverted string sets
Strings support all set operations, so you can union, intersect, and complement them.
This means that "the set of all strings expect "foo"" is a valid primitive type.
The "all strings except X" types are called inverted string sets.
While inverted string sets are a primitive type, they do not have special syntax.
They are denoted using the invStrSet function.
E.g. invStrSet("foo" | "bar") is the set of all strings except "foo" and "bar".
While this is awkward to use, they also don't come up a lot.
Structure types
Structure types are class-like product types.
They have a name and any number of fields.
Structures with no fields are constants (e.g. null).
E.g. Image { width: uint, height: uint, channels: uint } and null are structure types.
Structure type definitions
All structure types have a type definition.
The structure type definition specifies the fields all of its structure types will have.
E.g. struct Image { width: uint, height: uint, channels: uint } and struct null; are structure type definitions.
Fields are declared as name: type pairs.
Field names have to be unique per structure definition.
The field type may be any type except never. E.g. struct Foo { bar: never } is invalid.
Instantiation
All fields are optional when instantiating a type from a structure definition.
So Image, Image { width: uint }, Image { height: uint }, and Image { width: uint, height: uint, channels: uint } all create the same type given the above type definition for Image.
If any field is assigned never, then the resulting type will also be never. Example:
let image = Image { width: uint, height: never };
// image is neverSpread
Just like in JavaScript, it is possible to spread a structure type into another structure type using the ... operator.
Example:
struct Image { width: uint, height: uint, channels: uint }
let imageA = Image { width: 100, height: 100, channels: 3 };
let imageB = Image { ...imageA, width: 200 };
// imageB is Image { width: 200, height: 100, channels: 3 }The structure type of the spread expression doesn't have to be the same as the structure type it is spread into. Only the field names have to be compatible.
Example:
struct Size { width: uint, height: uint }
struct Image { width: uint, height: uint, channels: uint }
let size = Size { width: 100, height: 100 };
let image = Image { ...size, channels: 3 };
// image is Image { width: 100, height: 100, channels: 3 }New type pattern
Structure types are only equivalent if they have the same definition and all their fields are equivalent.
This means that each structure type definitions creates a new type of structure types.
Generics
Each structure definition is generic over all its field types.
E.g. Image { width: int(100..) } is the type of all images with a width of at least 100.
Field access
The type of a field can be accessed using the .name syntax. Example:
let image = Image { width: 100, height: 200, channels: 3 };
let width = image.width;
// width is 100Since fields are optional and default to the definition type, it's possible to access the definition type of a field like this:
let widthDef = Image.width;
// widthDef is uintVariable definitions
There is no proper programming language without variables and Navi has them too of course.
Variables are defined using the let keyword. Examples:
let int = int(..);
let uint = int(0..);
let a = 1;
let b = a + 1; // b will be 2Note that all Navi variables are immutable. Once a value is assigned, it cannot be changed.
While this is a limitation, immutability also allows for variables to be defined in any order. Example:
let b = a + 1; // b will still be 2
let a = 1;However, be careful not to create cyclical variable references. Example:
let x = x + 1; // self-referential variables are not allowed
let a = b + 1;
let b = a + 1; // cyclic references are also forbiddenExample: boolean type
Navi intentionally does not include a boolean primitive.
This is because it doesn't need one.
Boolean values can only be true or false, so the bool type is implemented like this:
struct false;
struct true;
let bool = false | true;Note that this is not the only possible implementation for a boolean type.
We simply need 2 disjoint types to represent the true and false variants.
So a sum type can also be defined using numeric literals (e.g. let bool = 0 | 1;) or string literals (e.g. let bool = "true" | "false";).
Type assertions
Variables may have an optional type assertion.
E.g. let a: int = foo;.
If the value assigned to the variable is not assignable to the asserted type, an evaluation error will be thrown.
This is mostly useful to:
- Document the intended type of a variable.
- Verify the correctness of complex expressions.
Scopes
Scopes are a tool to limit the access of variables and structures/function definitions. They are denoted with curly brackets {} and are themselves expressions. Example:
let a = {
let b = 1;
b + 1
}; // b is no longer accessible after the scope ends
// a is 2
let c = b; // reference errorNote that the last expression in a scope is the return value of the scope.
Enum definitions
Enums are syntactic sugar to more easily create sum types (disjoint union).
Example:
enum Direction { North, East, South, West }is equivalent to:
let Direction = Direction::North | Direction::East | Direction::South | Direction::West;
struct Direction::North;
struct Direction::East;
struct Direction::South;
struct Direction::West;The :: is the namespace accessor.
It allows names to be separated into groups.
Enum variants can also have fields.
Example:
enum Option { Some { value: any }, None }is equivalent to:
let Option = Option::Some | Option::None;
struct Option::Some { value: any }
struct Option::None;Function definitions
User-defined functions can be used to factor out common functionality and to create abstraction boundaries. Functions can have any number of parameters and must always return a value. Example:
def add(a: number, b: number) {
a + b
}The last expression in a function body is the return value. If the function body is only a single expression, a shorthand syntax can also be used:
def add(a: number, b: number) = a + b;Just like with variable definitions, functions can have an optional type assertion for the return type. Example:
def add(a: number, b: number): number {
a + b
}Generics
Just like how structs are generic over all their fields, functions are generic over all their parameters.
This property can be used to create types.
Example: Result type
Many languages (e.g. Rust) have a result type.
Here is how a result type would be implemented in Navi:
struct Success { value: any };
struct Error { value: any };
def Result(success: any, error: any) = Success { value: success } | Error { value: error };
# Instantiation
Result(int, string) == Success { value: int } | Error { value: string }
Result(int, never) == Success { value: int }`match`
match expressions enable conditional types and pattern matching on types.
let a = 1 | 3;
let b = match a {
1 => 0,
_ => a + 1
};
// b is 0 | 4This match expression behaves as "if a is 1, return 0, else return a plus 1."
1 => 0 and _ => a + 1 are called match arms or just arms.1 and _ are the patterns of the arms. Everything after the => is the expression of the arm. The expression of an arm is only evaluated if the pattern matches the input type. Lastly, _ is the default pattern and matches any type (it's similar to the default case in switch statement in TypeScript or C).
Arms are matched in order and parts of the input type matched by previous arms will not be matched again.
I.e. the _ => a + 1 arm did not match the 1 in 1 | 3 because it was already matched by 1 => 0.
Patterns
Every match arm has a pattern that describes which parts of the input type the arm handles.
A pattern is just a type, so 1, 1 | 4, -inf..0 | int(1..10) | inf, number, "foo", string, Image { width: 128 } are all valid patterns.
Note that an arm with a never pattern will never be matched.
Binding
Match arms can optionally bind the matched value to a variable. This is most useful when the matched value is an expression and not a variable. Example:
let a = 1 | 3;
let b = match a * 2 - 1 {
1 => 0,
_ as x => x + 1
};
// b is 0 | 6In this example, a * 2 - 1 evaluates to 1 | 5, so 1 => 0 handles the 1, and the _ as x arm matches the 5 and binds it to x.
Limitations
match is actually a little too powerful for Navi.
The problem is that match can be used to complement types, which breaks some assumptions the type system makes.
As a result, match arm patterns must be static expression.
However, this restriction is not enforced right now.
`if`/`else`
Navi's if-else expression is syntactic sugar for a boolean match expression.
The following two expressions are equivalent:
if condition { a } else { b }
match condition {
true => { a },
false => { b }
}Note that both branches require a scoped expression.
The curly brackets are mandatory.
`else if`
else if is also supported:
if condition1 { a } else if condition2 { b } else { c }And behaves like this:
if condition1 {
a
} else {
if condition2 { b } else { c }
}Built-in type definitions and functions
Just like other programming languages, Navi provides a set of built-in types and functions.
Types
let int = int(..);
let uint = int(0..);
struct true;
struct false;
let bool = true | false;More types are defined by chaiNNer.
Functions
Built-in functions behave just like user-defined functions, but they are not implemented with Navi but in the host language (in this case TypeScript). This allows them to express behavior that cannot be expressed in Navi.
For the full list of built-in functions, see global.navi.
Syntax sugar
Special syntax is available for some number::* functions.
This includes the following operators:
- Unary operators:
-afornumber::neg. - Binary operators:
a + b,a - b,a * b,a / b, anda ** bfor the respective functions. - Comparison operators:
a > b,a >= b,a < b, anda <= bfor the respective functions.
Operator precedence is as you would expect it to be.
E.g. a + b * c de-sugars to number::add(a, number::mul(b, c)).
Syntax sugar is available for any::eq and any::neq. They use == and != respectively.
Logical operations also have Python-like syntax. E.g. not a and b is equivalent to bool::and(bool::not(a), b).
Rules of the type system
This is technical section intended for those implementing the type system.
If you are only using Navi and don't plan on implementing new type primitives or built-in functions, you can ignore this section.
Requirements for primitive types
There are a few requirements for primitive types that are necessary for the type system to work:
- The intersection and union of any two primitives must be unique.
- The intersection of two primitives must either the empty (
never) or a primitive. - For any set that can be represented as a union of primitives, there must only be one union of primitives that represents the set.
Requirements for built-in functions
See https://github.com/chaiNNer-org/Navi/issues/3.
Set representation of structs
Internally, structure types are represented as a tuple (name, field_1, field_2, ..., field_n).
E.g. the set representation of the above Image structure is ("Image", uint, uint, uint).
This means that it is of utmost importance that the order of fields is consistent when creating structure types.
While the order of fields doesn't matter to users, it is vital for the implementation.
Static expressions
Static expression are not defined in terms of what they are, but in terms of what they are not.
Rules:
- All parameters (scope parameters and function parameters) are not static expressions.
- The result of an expression that references a non-static expression is also a non-static expression.
File: package.json
{
"name": "@chainner/navi",
"version": "0.8.0",
"description": "A set-theory based type system used in chaiNNer",
"main": "./dist/main.js",
"module": "./dist/module.mjs",
"types": "./dist/main.d.ts",
"exports": "./dist/main.js",
"files": [
"dist/**/*"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "rimraf dist/ && rollup -c",
"prepublishOnly": "npm run build",
"lint": "eslint . --ext ".js,.ts"",
"lint:fix": "eslint . --fix --ext ".js,.ts"",
"lint:ci": "eslint . --ext ".js,.ts" --max-warnings 0",
"test": "jest",
"test:update": "jest -u",
"antlr4": "npx ts-node ./scripts/antlr4.ts"
},
"repository": {
"type": "git",
"url": "git+https://github.com/chaiNNer-org/Navi.git"
},
"keywords": [
"chaiNNer"
],
"author": "Michael Schmidt",
"license": "MIT",
"bugs": {
"url": "https://github.com/chaiNNer-org/Navi/issues"
},
"homepage": "https://github.com/chaiNNer-org/Navi#readme",
"engines": {
"node": ">=16.0.0"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-replace": "^5.0.5",
"@rollup/plugin-run": "^3.0.2",
"@rollup/plugin-typescript": "^11.1.6",
"@types/antlr4": "^4.7.3",
"@types/jest": "^29.1.1",
"@types/node": "^18.14.1",
"@typescript-eslint/eslint-plugin": "^5.39.0",
"@typescript-eslint/parser": "^5.39.0",
"antlr4": "^4.13.1-patch-1",
"eslint": "^8.24.0",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-typescript": "^3.5.1",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-prefer-arrow-functions": "^3.1.4",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-sort-exports": "^0.7.0",
"eslint-plugin-unused-imports": "^2.0.0",
"jest": "^29.1.2",
"nodejs-file-downloader": "^4.10.2",
"prettier": "^2.7.1",
"rimraf": "^4.1.2",
"rollup": "^4.14.1",
"ts-jest": "^29.0.3",
"ts-node": "^10.9.1",
"typescript": "^4.8.4"
}
}
File: tsconfig.json
{
"compilerOptions": {
"emitDeclarationOnly": true,
"outDir": "dist",
"declaration": true,
"noImplicitAny": true,
"module": "ESNext",
"target": "es6",
"lib": ["es2019"],
"allowJs": true,
"checkJs": false,
"strict": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"moduleResolution": "node",
"esModuleInterop": true },
"include": [
"src",
]
}
File: .prettierrc.json
{
"printWidth": 100,
"singleQuote": true,
"singleAttributePerLine": true,
"tabWidth": 4
}
File: scripts/tsconfig.json
{
"compilerOptions": {
"noEmit": true,
"noImplicitAny": true,
"module": "CommonJS",
"target": "ES2020",
"allowJs": true,
"checkJs": false,
"strict": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"moduleResolution": "node",
"esModuleInterop": true
},
"include": [
"scripts",
]
}
File: .github/workflows/ci.yml
This is a basic workflow to help you get started with Actions
name: CI
Controls when the workflow will run
on:
pull_request:
branches: ['*']
types:
- opened
- synchronize
- closed
push:
branches: [main]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js 20.x
uses: actions/setup-node@v1
with:
node-version: 20.x
- run: npm ci
- name: eslint
run: npm run lint:ci
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js 20.x
uses: actions/setup-node@v1
with:
node-version: 20.x
- run: npm ci
- run: npx tsc
- run: npm run build
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js 20.x
uses: actions/setup-node@v1
with:
node-version: 20.x
- run: npm ci
- run: npm test