├── .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 | | Screenshot of YouTube playlist | Screenshot of Ottie tool | 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 | } --------------------------------------------------------------------------------