├── .gitignore
├── package.json
├── README.md
├── index.ts
├── LICENSE
├── .github
└── workflows
│ ├── auto-dependabot.yaml
│ └── ci.yaml
├── w.ts
├── m.ts
├── models.ts
├── parser.ts
└── helper.ts
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "scripts": {
3 | "start": "nodemon --exec 'tsx' index.ts",
4 | "build": "tsc --noEmit --lib esnext,dom *.ts",
5 | "test": "tsx index.ts"
6 | },
7 | "dependencies": {
8 | "@masala/parser": "^1.0.0",
9 | "nodemon": "^3.1.4",
10 | "tsx": "^4.19.3",
11 | "typescript": "^5.6.2"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # hindley-milner-typescript-minimal
2 |
3 | Extra minimal TypeScript implementation of Hindley-Milner type inference
4 |
5 | ## Also see
6 |
7 | | [YouTube Channel](https://www.youtube.com/playlist?list=PLoyEIY-nZq_uipRkxG79uzAgfqDuHzot-) | [Ottie](https://github.com/domdomegg/ottie) |
8 | |-------------|-------------|
9 | | A YouTube channel covering Hindley-Milner from first principles, intended to be accessible for people not already very familiar with functional languages or type theory. | A fuller implementation of Hindley-Milner type inference, on a richer language, combined with a web interface that makes inspecting what it's doing easier. |
10 | |
|
|
11 |
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
1 | import { newTypeVar, Substitution } from "./helper";
2 | import { M } from "./m";
3 | import { makeContext, MonoType } from "./models";
4 | import { parse } from "./parser";
5 | import { W } from "./w";
6 |
7 | const type = newTypeVar();
8 |
9 | console.dir(W(
10 | makeContext({
11 | 'not': { type: 'ty-app', C: '->', mus: [
12 | { type: 'ty-app', C: 'Bool', mus: [] },
13 | { type: 'ty-app', C: 'Bool', mus: [] },
14 | ] },
15 | 'odd': { type: 'ty-app', C: '->', mus: [
16 | { type: 'ty-app', C: 'Int', mus: [] },
17 | { type: 'ty-app', C: 'Bool', mus: [] },
18 | ] },
19 | 'add': { type: 'ty-app', C: '->', mus: [
20 | { type: 'ty-app', C: 'Int', mus: [] },
21 | { type: 'ty-app', C: '->', mus: [
22 | { type: 'ty-app', C: 'Int', mus: [] },
23 | { type: 'ty-app', C: 'Int', mus: [] },
24 | ] },
25 | ] },
26 | 'true': { type: 'ty-app', C: 'Bool', mus: [] },
27 | 'false': { type: 'ty-app', C: 'Bool', mus: [] },
28 | 'one': { type: 'ty-app', C: 'Int', mus: [] },
29 | }),
30 | parse('odd one'),
31 | )[1], { depth: Infinity });
32 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Adam Jones
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.github/workflows/auto-dependabot.yaml:
--------------------------------------------------------------------------------
1 | # This file is centrally managed
2 | # https://github.com/domdomegg/domdomegg/blob/master/file-sync/auto-dependabot.yaml
3 |
4 | name: Dependabot automation
5 |
6 | on:
7 | pull_request:
8 | types:
9 | - opened
10 | - reopened
11 | - synchronize
12 | - edited
13 | - ready_for_review
14 | - unlabeled
15 |
16 | permissions:
17 | pull-requests: write
18 | contents: write
19 |
20 | jobs:
21 | dependabot_automation:
22 | runs-on: ubuntu-latest
23 | timeout-minutes: 10
24 | if: ${{ github.actor == 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository}}
25 | steps:
26 | - name: Approve
27 | run: gh pr review --approve "$PR_URL"
28 | env:
29 | PR_URL: ${{github.event.pull_request.html_url}}
30 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
31 | - name: Enable auto-merge
32 | if: ${{ !contains(github.event.pull_request.labels.*.name, 'do not merge') }}
33 | run: gh pr merge --auto --squash "$PR_URL"
34 | env:
35 | PR_URL: ${{github.event.pull_request.html_url}}
36 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
37 |
--------------------------------------------------------------------------------
/w.ts:
--------------------------------------------------------------------------------
1 | import { generalise, instantiate, makeSubstitution, newTypeVar, Substitution, unify } from "./helper";
2 | import { Context, Expression, makeContext, MonoType } from "./models";
3 |
4 | export const W = (typEnv: Context, expr: Expression): [Substitution, MonoType] => {
5 | if (expr.type === "var") {
6 | const value = typEnv[expr.x];
7 | if (value === undefined) throw new Error(`Undefined variable: ${expr.x}`);
8 | return [makeSubstitution({}), instantiate(value)]
9 | }
10 |
11 | if (expr.type === "abs") {
12 | const beta = newTypeVar();
13 | const [s1, t1] = W(makeContext({
14 | ...typEnv,
15 | [expr.x]: beta,
16 | }), expr.e)
17 | return [s1, s1({
18 | type: 'ty-app',
19 | C: '->',
20 | mus: [beta, t1]
21 | })]
22 | }
23 |
24 | if (expr.type === "app") {
25 | const [s1, t1] = W(typEnv, expr.e1)
26 | const [s2, t2] = W(s1(typEnv), expr.e2)
27 | const beta = newTypeVar()
28 |
29 | const s3 = unify(s2(t1), {
30 | type: 'ty-app',
31 | C: '->',
32 | mus: [t2, beta]
33 | })
34 | return [s3(s2(s1)), s3(beta)]
35 | }
36 |
37 | if (expr.type === "let") {
38 | const [s1, t1] = W(typEnv, expr.e1)
39 | const [s2, t2] = W(makeContext({
40 | ...s1(typEnv),
41 | [expr.x]: generalise(typEnv, t1),
42 | }), expr.e2)
43 | return [s2(s1), t2]
44 | }
45 |
46 | throw new Error('Unknown expression type')
47 | }
48 |
--------------------------------------------------------------------------------
/m.ts:
--------------------------------------------------------------------------------
1 | import { generalise, instantiate, newTypeVar, Substitution, unify } from "./helper";
2 | import { Context, Expression, makeContext, MonoType } from "./models";
3 |
4 | export const M = (typEnv: Context, expr: Expression, type: MonoType): Substitution => {
5 | if (expr.type === "var") {
6 | console.log(`Variable ${expr.x}: expected to have type ${JSON.stringify(type)}`)
7 |
8 | const value = typEnv[expr.x];
9 | if (value === undefined) throw new Error(`Undefined variable: ${expr.x}`);
10 | return unify(type, instantiate(value))
11 | }
12 |
13 | if (expr.type === "abs") {
14 | const beta1 = newTypeVar();
15 | const beta2 = newTypeVar();
16 | const s1 = unify(type, {
17 | type: 'ty-app',
18 | C: '->',
19 | mus: [beta1, beta2]
20 | })
21 | const s2 = M(
22 | makeContext({
23 | ...s1(typEnv),
24 | [expr.x]: s1(beta1)
25 | }),
26 | expr.e,
27 | s1(beta2),
28 | );
29 | return s2(s1);
30 | }
31 |
32 | if (expr.type === "app") {
33 | const beta = newTypeVar()
34 | const s1 = M(typEnv, expr.e1, {
35 | type: 'ty-app',
36 | C: '->',
37 | mus: [beta, type]
38 | })
39 | const s2 = M(s1(typEnv), expr.e2, s1(beta))
40 | return s2(s1)
41 | }
42 |
43 | if (expr.type === "let") {
44 | const beta = newTypeVar()
45 | const s1 = M(typEnv, expr.e1, beta)
46 | const s2 = M(makeContext({
47 | ...s1(typEnv),
48 | [expr.x]: generalise(s1(typEnv), s1(beta))
49 | }), expr.e2, s1(type))
50 | return s2(s1);
51 | }
52 |
53 | throw new Error('Unknown expression type')
54 | }
--------------------------------------------------------------------------------
/models.ts:
--------------------------------------------------------------------------------
1 | // Expressions
2 |
3 | // e ::= x
4 | // | e1 e2
5 | // | \x -> e
6 | // | let x = e1 in e2
7 |
8 | export type Expression =
9 | | VariableExpression
10 | | ApplicationExpression
11 | | AbstractionExpression
12 | | LetExpression;
13 |
14 | export interface VariableExpression {
15 | type: 'var',
16 | x: string,
17 | }
18 |
19 | export interface ApplicationExpression {
20 | type: 'app',
21 | e1: Expression,
22 | e2: Expression,
23 | }
24 |
25 | export interface AbstractionExpression {
26 | type: 'abs',
27 | x: string,
28 | e: Expression,
29 | }
30 |
31 | export interface LetExpression {
32 | type: 'let',
33 | x: string,
34 | e1: Expression,
35 | e2: Expression,
36 | }
37 |
38 | // Types
39 |
40 | // mu ::= a
41 | // | C mu_0 ... mu_n
42 |
43 | // sigma ::= mu
44 | // | Va. sigma
45 |
46 | export type MonoType =
47 | | TypeVariable
48 | | TypeFunctionApplication
49 |
50 | export type PolyType =
51 | | MonoType
52 | | TypeQuantifier
53 |
54 | export type TypeFunction = "->" | "Bool" | "Int" | "List"
55 |
56 | export interface TypeVariable {
57 | type: 'ty-var',
58 | a: string,
59 | }
60 |
61 | export interface TypeFunctionApplication {
62 | type: 'ty-app',
63 | C: TypeFunction,
64 | mus: MonoType[],
65 | }
66 |
67 | export interface TypeQuantifier {
68 | type: 'ty-quantifier',
69 | a: string,
70 | sigma: PolyType,
71 | }
72 |
73 | // Contexts
74 |
75 | export const ContextMarker = Symbol()
76 | export type Context = { [ContextMarker]: boolean, [variable: string]: PolyType }
77 |
78 | export const makeContext = (raw: { [ContextMarker]?: boolean, [variable: string]: PolyType }): Context => {
79 | raw[ContextMarker] = true;
80 | return raw as Context;
81 | }
82 |
83 | export const isContext = (something: unknown): something is Context => {
84 | return typeof something === "object" && something !== null && ContextMarker in something
85 | }
86 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yaml:
--------------------------------------------------------------------------------
1 | # This file is centrally managed
2 | # https://github.com/domdomegg/domdomegg/blob/master/file-sync/node-general.yaml
3 |
4 | name: CI/CD
5 |
6 | on:
7 | push:
8 | branches: [master]
9 | tags:
10 | - 'v*'
11 | pull_request:
12 |
13 | jobs:
14 | ci:
15 | runs-on: ubuntu-latest
16 | timeout-minutes: 10
17 | strategy:
18 | matrix:
19 | node-version: [lts/*, current]
20 | env:
21 | CI: true
22 | steps:
23 | - name: Checkout ${{ github.sha }}
24 | uses: actions/checkout@v4
25 | - name: Use Node.js ${{ matrix.node-version }}
26 | uses: actions/setup-node@v4
27 | with:
28 | node-version: ${{ matrix.node-version }}
29 | registry-url: https://registry.npmjs.org/
30 | - name: Install dependencies
31 | run: npm ci
32 | - name: Lint
33 | run: npm run lint --if-present
34 | - name: Build
35 | run: npm run build --if-present
36 | - name: Test
37 | run: npm run test --if-present
38 |
39 | deploy:
40 | if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push'
41 | needs: ci
42 | runs-on: ubuntu-latest
43 | timeout-minutes: 10
44 | permissions:
45 | contents: read
46 | id-token: write
47 | env:
48 | CI: true
49 | steps:
50 | - name: Checkout ${{ github.sha }}
51 | uses: actions/checkout@v4
52 | - name: Use Node.js with the npmjs.org registry
53 | uses: actions/setup-node@v4
54 | with:
55 | node-version: lts/*
56 | registry-url: https://registry.npmjs.org/
57 | - name: Install dependencies
58 | run: npm ci
59 | - name: Build
60 | run: npm run build --if-present
61 | - uses: google-github-actions/auth@v2
62 | with:
63 | workload_identity_provider: 'projects/457105351064/locations/global/workloadIdentityPools/github-secrets-pool/providers/github-secrets-github'
64 | - uses: google-github-actions/setup-gcloud@v2
65 | - name: Get NPM token
66 | id: npm-token
67 | run: |
68 | token=$(gcloud secrets versions access latest --secret=npm-token --project=gcp-github-secrets)
69 | echo "::add-mask::$token"
70 | echo "token=$token" >> "$GITHUB_OUTPUT"
71 | - name: Publish ${{ github.ref }}
72 | run: npm publish
73 | env:
74 | NODE_AUTH_TOKEN: ${{ steps.npm-token.outputs.token }}
75 |
--------------------------------------------------------------------------------
/parser.ts:
--------------------------------------------------------------------------------
1 | import { C, F, GenLex, SingleParser, Streams } from "@masala/parser";
2 | import { AbstractionExpression, Expression, LetExpression, VariableExpression } from "./models";
3 |
4 | // Lexer
5 |
6 | const genlex = new GenLex();
7 | const identifier = genlex.tokenize(C.charIn('abcdefghijklmnopqrstuvwxyz').rep().map(t => t.join()), 'identifier')
8 | const backslash = genlex.tokenize(C.char('\\'), 'backslash')
9 | const arrow = genlex.tokenize(C.string('->'), 'arrow')
10 | const letTok = genlex.tokenize(C.string('let '), 'let')
11 | const inTok = genlex.tokenize(C.string('in '), 'in')
12 | const equals = genlex.tokenize(C.char('='), 'equals')
13 | const lparen = genlex.tokenize(C.char('('), 'lparen')
14 | const rparen = genlex.tokenize(C.char(')'), 'rparen')
15 |
16 | // Parser
17 |
18 | // e ::= x
19 | // | e1 e2
20 | // | \x -> e
21 | // | let x = e1 in e2
22 |
23 | const expressionParser = (): SingleParser => F.try(variableExpression())
24 | .or(F.try(abstractionExpression()))
25 | .or(F.try(letExpression()))
26 | .or(F.try(paren()))
27 | .rep()
28 | .array()
29 | .map(collectToFunctionApplication)
30 |
31 | const variableExpression = (): SingleParser =>
32 | identifier
33 | .map(x => ({
34 | type: 'var',
35 | x,
36 | }))
37 |
38 | const abstractionExpression = (): SingleParser =>
39 | backslash
40 | .then(identifier)
41 | .then(arrow)
42 | .then(F.lazy(expressionParser))
43 | .map(tuple => ({
44 | type: 'abs',
45 | x: tuple.at(1) as string,
46 | e: tuple.at(3) as Expression,
47 | }))
48 |
49 | const letExpression = (): SingleParser =>
50 | letTok
51 | .then(identifier)
52 | .then(equals)
53 | .then(F.lazy(expressionParser))
54 | .then(inTok)
55 | .then(F.lazy(expressionParser))
56 | .map(tuple => ({
57 | type: 'let',
58 | x: tuple.at(1) as string,
59 | e1: tuple.at(3) as Expression,
60 | e2: tuple.at(5) as Expression,
61 | }))
62 |
63 | const paren = (): SingleParser =>
64 | lparen.drop()
65 | .then(F.lazy(expressionParser))
66 | .then(rparen.drop())
67 | .single()
68 |
69 | const collectToFunctionApplication = (es: Expression[]): Expression => {
70 | if (es.length === 1) return es[0];
71 | if (es.length === 2) return {
72 | type: 'app',
73 | e1: es[0],
74 | e2: es[1],
75 | }
76 |
77 | // (e0 e1) e2
78 | // ((e0 e1) e2) e3
79 | return {
80 | type: 'app',
81 | e1: collectToFunctionApplication(es.slice(0, -1)),
82 | e2: es[es.length - 1],
83 | }
84 | }
85 |
86 | const parser: SingleParser = genlex.use(expressionParser().then(F.eos().drop()).single())
87 |
88 | export const parse = (code: string): Expression => {
89 | const res = parser.parse(Streams.ofString(code))
90 | if (res.isAccepted()) {
91 | return res.value;
92 | }
93 | throw new Error('Failed to parse')
94 | }
--------------------------------------------------------------------------------
/helper.ts:
--------------------------------------------------------------------------------
1 | import { Context, isContext, makeContext, MonoType, PolyType, TypeVariable } from "./models";
2 |
3 | // substitutions
4 |
5 | export type Substitution = {
6 | type: 'substitution',
7 | (m: MonoType): MonoType;
8 | (t: PolyType): PolyType;
9 | (c: Context): Context;
10 | (s: Substitution): Substitution;
11 | raw: { [typeVariables: string]: MonoType }
12 | }
13 |
14 | export const makeSubstitution = (raw: Substitution["raw"]): Substitution => {
15 | const fn = ((arg: MonoType | PolyType | Context | Substitution) => {
16 | if (arg.type === "substitution") return combine(fn, arg)
17 | return apply(fn, arg);
18 | }) as Substitution
19 | fn.type = 'substitution';
20 | fn.raw = raw;
21 | return fn;
22 | }
23 |
24 | function apply(substitution: Substitution, value: T): T;
25 | function apply(s: Substitution, value: MonoType | PolyType | Context): MonoType | PolyType | Context {
26 | if (isContext(value)) {
27 | return makeContext(Object.fromEntries(
28 | Object.entries(value)
29 | .map(([k, v]) => [k, apply(s, v)])
30 | ))
31 | }
32 |
33 | if (value.type === "ty-var") {
34 | if (s.raw[value.a]) return s.raw[value.a];
35 | return value;
36 | }
37 |
38 | if (value.type === "ty-app") {
39 | return { ...value, mus: value.mus.map((m) => apply(s, m)) };
40 | }
41 |
42 | if (value.type === "ty-quantifier") {
43 | // If the quantifier variable conflicts with any substitution...
44 | if (s.raw[value.a] || Object.values(s.raw).some(t => freeVars(t).includes(value.a))) {
45 | // Rename the quantifier variable
46 | const aPrime = newTypeVar();
47 | const renamedSigma = apply(makeSubstitution({ [value.a]: aPrime }), value.sigma);
48 |
49 | // Apply the original substitution to the renamed sigma
50 | return {
51 | ...value,
52 | a: aPrime.a,
53 | sigma: apply(s, renamedSigma)
54 | };
55 | }
56 |
57 | return { ...value, sigma: apply(s, value.sigma) };
58 | }
59 |
60 | throw new Error('Unknown argument passed to substitution')
61 | }
62 |
63 | const combine = (s1: Substitution, s2: Substitution): Substitution => {
64 | return makeSubstitution({
65 | ...s1.raw,
66 | ...Object.fromEntries(Object.entries(s2.raw).map(([k, v]) => [k, s1(v)]))
67 | })
68 | }
69 |
70 | // new type variable
71 | let currentTypeVar = 0;
72 | export const newTypeVar = (): TypeVariable => ({
73 | type: 'ty-var',
74 | a: `t${currentTypeVar++}`
75 | })
76 |
77 | // instantiate
78 | // mappings = { a |-> t0, b |-> t1 }
79 | // Va. Vb. a -> b
80 | // t0 -> t1
81 | export const instantiate = (
82 | type: PolyType,
83 | mappings: Map = new Map()
84 | ): MonoType => {
85 | if (type.type === "ty-var") {
86 | return mappings.get(type.a) ?? type;
87 | }
88 |
89 | if (type.type === "ty-app") {
90 | return { ...type, mus: type.mus.map((m) => instantiate(m, mappings)) };
91 | }
92 |
93 | if (type.type === "ty-quantifier") {
94 | mappings.set(type.a, newTypeVar());
95 | return instantiate(type.sigma, mappings);
96 | }
97 |
98 | throw new Error('Unknown type passed to instantiate')
99 | }
100 |
101 | // generalise
102 | export const generalise = (ctx: Context, type: MonoType): PolyType => {
103 | const quantifiers = diff(freeVars(type), freeVars(ctx));
104 | let t: PolyType = type;
105 | quantifiers.forEach(q => {
106 | t = { type: 'ty-quantifier', a: q, sigma: t }
107 | })
108 | return t;
109 | }
110 |
111 | const diff = (a: T[], b: T[]): T[] => {
112 | const bset = new Set(b);
113 | return a.filter(v => !bset.has(v))
114 | }
115 |
116 | const freeVars = (value: PolyType | Context): string[] => {
117 | if (isContext(value)) {
118 | return Object.values(value).flatMap(freeVars)
119 | }
120 |
121 | if (value.type === "ty-var") {
122 | return [value.a];
123 | }
124 |
125 | if (value.type === "ty-app") {
126 | return value.mus.flatMap(freeVars)
127 | }
128 |
129 | if (value.type === "ty-quantifier") {
130 | return freeVars(value.sigma).filter(v => v !== value.a)
131 | }
132 |
133 | throw new Error('Unknown argument passed to substitution')
134 | }
135 |
136 | // unify
137 |
138 | export const unify = (type1: MonoType, type2: MonoType): Substitution => {
139 | if (type1.type === "ty-var" && type2.type === "ty-var" && type1.a === type2.a) {
140 | return makeSubstitution({})
141 | }
142 |
143 | if (type1.type === "ty-var") {
144 | if (contains(type2, type1)) throw new Error('Infinite type detected')
145 |
146 | return makeSubstitution({
147 | [type1.a]: type2
148 | })
149 | }
150 |
151 | if (type2.type === "ty-var") {
152 | return unify(type2, type1)
153 | }
154 |
155 | if (type1.C !== type2.C) {
156 | throw new Error(`Could not unify types (different type functions): ${type1.C} and ${type2.C}`)
157 | }
158 |
159 | if (type1.mus.length !== type2.mus.length) {
160 | throw new Error(`Could not unify types (different argument lengths): ${type1} and ${type2}`)
161 | }
162 |
163 | let s: Substitution = makeSubstitution({})
164 | for (let i = 0; i < type1.mus.length; i++) {
165 | s = (unify(s(type1.mus[i]), s(type2.mus[i])))(s)
166 | }
167 | return s;
168 | }
169 |
170 | const contains = (value: MonoType, type2: TypeVariable): boolean => {
171 | if (value.type === "ty-var") {
172 | return value.a === type2.a;
173 | }
174 |
175 | if (value.type === "ty-app") {
176 | return value.mus.some((t) => contains(t, type2))
177 | }
178 |
179 | throw new Error('Unknown argument passed to substitution')
180 | }
--------------------------------------------------------------------------------