Skip to main content

Pyfun

PyPI VS Code Marketplace License: Apache 2.0

Functional programming for the language classrooms already teach.

Pyfun is an F#-inspired, functional-first language that compiles to readable Python. It brings algebraic data types, exhaustive matching, currying, inferred effects, and units of measure to the Python ecosystem, and its Rust compiler checks every one of them before a single line of Python is emitted.

It exists to make functional programming teachable where students already are. CS courses run on Python; learning FP usually means leaving it for Haskell, OCaml, or F# and adopting a whole new ecosystem students rarely touch again. Pyfun keeps them in Python, with no new runtime and no new package manager, and compiles to Python they can read, so every concept stays visible in the code they already understand.

โ–ถ Try it in your browser โ€” no install: write Pyfun, watch it compile to readable Python live, and hit Run to execute it (the compiler runs as WebAssembly, the Python runs in CPython-via-WASM).

๐Ÿ“– Learn Pyfun โ€” a short, graded course for people who know some Python: one idea per lesson, exercises checked by the compiler, solved in the browser.

Or install it with Python 3.12+:

pip install pyfun-lang
type Shape = Circle float | Rect float float

# `area` handles Circle but forgets Rect, so Pyfun refuses to compile it:
let area s =
  match s:
    case Circle r: 3.14159 * r * r
$ pyfun check shapes.pyfun
error: non-exhaustive match: `Rect _ _` is not matched
 --> 5:3
  |
5 |   match s:
  |   ^^^^^^^^

Plain Python compiles and runs this, then silently returns None the day a Rect reaches it, and you debug the TypeError an hour downstream. Pyfun's Rust compiler checks types, effects, units, and match exhaustiveness before a single line of Python is emitted, then hands you code you can read, diff, and ship.

And when it compiles, the output is the point. There's no runtime library to ship and nothing to read around โ€” your match becomes Python's match/case, one for one:

let grade score =
  match score:
    case s if s >= 90: "A"
    case s if s >= 80: "B"
    case _: "C"
# exactly what `pyfun compile` emits โ€” no wrappers, no runtime:
def grade(score):
    match score:
        case s if s >= 90:
            return "A"
        case s if s >= 80:
            return "B"
        case _:
            return "C"

A Pyfun List is a Python list, a record is a plain class, and extern calls a real library directly (fuller example below).


Made for the classroom

Teaching FP normally forces a detour: a new language, a new toolchain, and a new ecosystem the students abandon the moment the course ends. Pyfun removes the detour.

  • They already have the runtime. Pyfun compiles to plain Python, so anything a student writes runs on the interpreter already installed on every lab machine. No VM, no new package manager.
  • The concepts stay visible. pyfun compile shows the Python your functional code becomes, so a student watches an ADT turn into a class, a match into match/case, and currying into a closure. They learn the idea and how it maps to the imperative code they know.
  • Good habits are enforced, not suggested. The compiler refuses to skip a case, ignore a None, or mutate what should stay immutable, so students learn to handle every path because the tool insists.
  • A small, learnable core. Pyfun is deliberately compact, so the language stays out of the way of the ideas you are teaching.

Pyfun is a real, general-purpose language, not a toy. But teaching is why it exists. The Learn Pyfun course turns this into practice: a graded course of short lessons with compiler-checked exercises, runnable in the browser, free to use and adapt in a classroom (CC BY 4.0).


Why Pyfun over plain Python?

Python is the best ecosystem in the world, and even with mypy/pyright bolted on, large Python programs still fail in ways a compiler could have caught. Pyfun keeps the ecosystem and makes the checks mandatory.

Plain Python Pyfun
Type errors mypy/pyright are optional and unsound; they warn, they don't gate found at compile time; no Python is emitted until they pass
None handling AttributeError: 'NoneType'โ€ฆ Option a with exhaustive match; the compiler makes you handle None
Missing case silently falls through, returns None exhaustiveness error with a concrete missing-case witness
Mutation everything is mutable, everywhere immutable by default; let mut + <- is opt-in and tracked
Side effects invisible inferred and tracked; let pure is a compile-checked promise
Units / dimensions a comment and a prayer 100<m> / 10<s> : float<m/s>, checked and then erased
Runtime CPython CPython: Pyfun is Python once compiled

Why not just mypy/pyright? They're a gradual, optional overlay: unsound by design, never required, and one # type: ignore from silence. They report; they don't gate. Pyfun makes the same class of check mandatory: it blocks compilation, infers the signatures pyright often needs spelled out, and there is no untyped Pyfun to fall back to. And you keep the entire Python ecosystem while you do it.


Type-checked Python interop

extern imports any Python callable or value at a Pyfun type. The dotted target is imported for you; the boundary is effectful by default (a Python call can do anything), and pure opts out where you know better. Once imported, the function is a first-class curried Pyfun value: type-checked, effect-tracked, and partially applicable.

extern pure mean:  List float -> float = statistics.mean
extern pure stdev: List float -> float = statistics.stdev

type Summary = { n: int, mean: float, stdev: float }

let summarize xs =
  Summary { n = List.len xs, mean = mean xs, stdev = stdev xs }

let report xs =
  let s = summarize xs
  f"n={s.n} mean={s.mean} sd={s.stdev}"

print (report [1.0, 2.0, 3.0, 4.0])

pyfun compile turns that into Python you'd be happy to have written by hand:

from dataclasses import dataclass
import statistics

@dataclass(frozen=True)
class Summary:
    n: int
    mean: float
    stdev: float

def summarize(xs):
    return Summary(len(xs), statistics.mean(xs), statistics.stdev(xs))

def report(xs):
    s = summarize(xs)
    return f"n={s.n} mean={s.mean} sd={s.stdev}"

print(report([1.0, 2.0, 3.0, 4.0]))
$ pyfun run stats.pyfun
n=4 mean=2.5 sd=1.2909944487358056

Notice what the compiler does:

  • No wrapper layer. statistics.mean(xs) is called directly. List is a Python list, and a record or ADT variant is a frozen @dataclass โ€” so frozen=True even enforces in the Python the immutability Pyfun promises. There is no runtime, no VM, no marshalling.
  • Effects tracked across the boundary. A bare extern is io at full application, so it can't be called from a let pure. Mark it pure (like statistics.mean) and it composes into pure code. You can even annotate other effect labels: extern fetch: string ->{async} string = httpx.get.
  • Exceptions become values. try (parseInt s) : Result int Exception catches whatever the Python side raises and hands you a Result to match on. The imperative FFI edge becomes the FP error type, with errorKind and errorMessage fields.
extern parseInt: string -> int = int          # Python's built-in int()

let safe s = Result.withDefault 0 (try (parseInt s))
print (safe "42")     # 42
print (safe "oops")   # 0   (the ValueError was caught into an Error)

A whistle-stop tour

Everything below type-checks, compiles, and runs today. See examples/hello.pyfun for the exhaustive version.

Algebraic data types, records, and exhaustive matching. None cannot bite you:

type Shape = Circle float | Rect float float

let area s =
  match s:
    case Circle r: 3.14159 * r * r
    case Rect w h: w * h
# forget a case and the compiler reports the missing witness, e.g. `Rect _ _ is not matched`

Decode untrusted JSON into typed data, totally. json.loads hands back an untyped dict that explodes three layers downstream. The built-in Elm-style Decode module turns JSON into your own records โ€” a missing field or wrong type is a value you handle, never an AttributeError an hour later:

type User = { name: string, age: int }

let user =
  Decode.map2 (fun name age -> User { name = name, age = age })
    (Decode.field "name" Decode.string)
    (Decode.field "age" Decode.int)

# Decode.decodeString user : string -> Result User Exception
#   good input   -> Ok (a typed User)
#   missing/bad  -> Error (a value describing exactly what was wrong)

The examples/interop/ cookbook calls json, sqlite3, pathlib, and urllib this way โ€” typed and effect-tracked at the boundary.

Pipelines, currying, composition. F#'s |>, <|, >>, <<, and operator sections (+):

let describe =
  List.filter (fun x -> x > 0)
  >> List.map ((*) 2)
  >> List.fold (+) 0

let total = [1, -2, 3] |> describe    # (1 + 3) * 2 = 8

Inferred effects. Purity is a checked promise, never boilerplate:

let pure add a b = a + b        # OK: no effects
# let pure shout n = print n    # compile error: `print` performs `io`

Units of measure. Dimensional analysis at compile time, erased at runtime:

measure m
measure s
measure kg
measure N = kg m / s^2          # derived aliases expand to base units

let speed = 100<m> / 10<s>      # float<m/s>
let force = 10<N>
# let bad = 100<m> + 10<s>      # compile error: m vs s
let side = sqrt 16.0<m^2>       # float<m>, unit-aware roots

Computation expressions (F#'s showcase feature): result, seq, async, plus your own:

let checked ok v =
  result {                      # railway-oriented; short-circuits on Error
    let! x = if ok then Ok v else Error "bad"
    return x + 1
  }

Rich literals and strings. F-strings, raw strings, triple-quotes, scientific notation, digit separators, hex/octal/binary:

let planck = 6.626e-34
let million = 1_000_000
let mask = 0xFF
let who = "Ada"
let line = f"{who} scored {million} ({String.upper who})"
let path = r"C:\Users\pyfun"    # raw string, backslashes literal

And a standard library that reads like F#'s: module-qualified List / Set / Map / Option / Result / Seq / String (List.map, Map.tryFind, Result.bind, lazy Seq.take, String.split), tuples, active patterns, typed holes for type-driven development, and multi-file projects with import.


How Pyfun compares

A few projects bring functional or statically-typed code to Python. Here is the field, and the bet Pyfun makes within it:

  • Fable compiles real F# to Python, the most capable option by far, because it is F#, with the whole language and a mature ecosystem. The trade-offs: it needs the .NET toolchain, and its output depends on a runtime library (fable_library).
  • Erg is a statically-typed, Python-compatible language with a rich type system and marker-based effect control. It is the closest to Pyfun in ambition, though "rusty"/OO rather than ML-family, with explicit effect annotations.
  • Coconut is a functional superset of Python; static typing is an optional MyPy add-on, so nothing is enforced.
  • Dynamic dialects (Hy, Mochi, Dogelang) are dynamically-typed FP/Lisp languages that run on Python; they share the last column, since they trade static guarantees for Python's dynamism.

Legend: โœ… yes ยท โš ๏ธ partial ยท โž– different approach ยท โŒ no

Pyfun Fable Erg Coconut Dynamic dialects
FP-first language (not a Python superset) โœ… โœ… โœ… โž– โš ๏ธ
ML / F#-family syntax โœ… โœ… โž– โž– โŒ
Mandatory static typing โœ… โœ… โœ… โŒ โŒ
Type inference โœ… โœ… โœ… โž– โŒ
Zero annotations required โœ… โš ๏ธ โš ๏ธ โŒ โž–
ADTs + enforced exhaustiveness โœ… โœ… โš ๏ธ โš ๏ธ โŒ
Inferred effects (never annotated) โœ… โŒ โž– โŒ โŒ
Units of measure โœ… โœ… โŒ โŒ โŒ
Computation expressions โœ… โœ… โŒ โŒ โŒ
Nested record-update ({ p with a.b = v }) โœ… โœ… โŒ โŒ โž–
Typed holes (type-driven dev) โœ… โŒ โŒ โŒ โŒ
Chained comparisons (a < b < c) โœ… โŒ โš ๏ธ โœ… โœ…
Compiler-as-gatekeeper โœ… โœ… โœ… โŒ โŒ
Self-contained output (no runtime library) โœ… โŒ โŒ โŒ โž–
No .NET / host-runtime toolchain โœ… โŒ โœ… โž– โž–
Python-library interop โœ… โœ… โœ… โœ… โœ…
Maturity / production use โŒ pre-1.0 โš ๏ธ Py beta โš ๏ธ โœ… โš ๏ธ
Language surface (built-in constructs) โš ๏ธ small core โœ… full F# โš ๏ธ โœ… Python superset โœ…
Community, docs, support โŒ solo โœ… โš ๏ธ โœ… โš ๏ธ

Pyfun's strengths are the bold rows: self-contained, runtime-free Python output (a List is a list, a record is a plain class), a single dependency-free compiler with no .NET, inferred effects, and a language designed for Python interop first. On several rows it reaches past F# itself, borrowing inferred effects from Koka, typed holes from Haskell and Idris, and Python-style chained comparisons. Every tool here reaches the full Python ecosystem (the interop row), so Pyfun's small core costs nothing in libraries; it just buys simplicity.

Reach for Fable when you want all of F# and are happy to bring the .NET toolchain and a runtime library along. Reach for Pyfun when you want the emitted Python to be a first-class, readable artifact you own outright, or when you are teaching functional programming to people who live in Python.


Getting started

Pyfun runs on the Python you already have. With Python 3.12+ and pip, install the compiler:

pip install pyfun-lang

That puts the pyfun command on your PATH, with no Rust toolchain required. (The PyPI package is pyfun-lang; the command it installs is pyfun.)

Write your first program. Save this as hello.pyfun:

type Shape = Circle float | Rect float float

let area s =
  match s:
    case Circle r: 3.14159 * r * r
    case Rect w h: w * h

print (area (Circle 2.0))

Then run it, type-check it, or see the Python it becomes:

pyfun run     hello.pyfun            # 12.56636
pyfun check   hello.pyfun            # type-check, rustc-style diagnostics
pyfun compile hello.pyfun            # emit readable Python to stdout
pyfun repl                           # interactive REPL

Multi-file projects just work: import Geometry pulls in a sibling geometry.pyfun, and any command drives the whole graph. Clone the repo for a runnable tour in examples/, including a multi-module project (pyfun run examples/modules/main.pyfun).

Building from source (or hacking on the compiler) needs Rust, which auto-selects the pinned 1.97 toolchain:

cargo install --git https://github.com/simontreanor/Pyfun pyfun
# or, from a clone:  cargo install --path .

Editor support

Pyfun ships a dependency-free language server (pyfun lsp) and a VS Code extension. Over resilient analysis that survives a half-typed file, you get:

  • Diagnostics as you type
  • Hover showing the inferred type and effect of any expression, binding, or parameter
  • Go-to-definition and find-references, across files
  • Rename, project-wide, for values, constructors, and types
  • Completion, document symbols, and workspace symbols

Install Pyfun from the VS Code Marketplace (or search "Pyfun" in the Extensions panel); once pyfun is on your PATH (from pip install pyfun-lang), it launches pyfun lsp automatically. Building the extension from source is covered in editors/vscode/DEVELOPMENT.md.

Not a VS Code user? Because the server is plain LSP over stdio, any editor with an LSP client works โ€” copy-paste configs for Neovim, Helix, and Emacs are in editors/README.md, along with a Tree-sitter grammar.


Jupyter

Pyfun ships a Jupyter kernel โ€” a type-checked notebook where definitions echo their inferred types and state persists across cells:

pip install "pyfun-lang[jupyter]"
python -m pyfun_kernel.install

Then pick the Pyfun kernel in JupyterLab, VS Code, or any notebook UI. Every cell is type-checked against the session before anything runs โ€” an ill-typed cell is rejected with rustc-style diagnostics and changes nothing. A cell can mix definitions with a trailing expression to display; re-running a cell re-runs the expression but not the definitions' effects (they ran once, at entry). Shift-Tab shows the inferred type of the identifier under the cursor.


How it works

Pyfun is a dependency-free Rust crate that runs a classic pipeline, and the compiler is the gatekeeper: nothing is emitted until every check passes.

.pyfun โ”€โ”€โ–บ lexer โ”€โ”€โ–บ parser โ”€โ”€โ–บ Hindleyโ€“Milner type inference โ”€โ”€โ–บ Python-AST IR โ”€โ”€โ–บ readable .py
              โ”‚         โ”‚        (+ effects, units, exhaustiveness)      โ”‚
          offside    recursive                                     lowered, not
           rule       descent                                    string-spliced
  • Type inference is full HM with let-generalization: you never annotate a value. The only types you write are in type/extern declarations, and every signature is inferred. It also does unit-of-measure inference (abelian-group unification), effect-row inference, and Maranget-style exhaustiveness with concrete witnesses.
  • Lowering targets a Python-AST IR and emits real, formatted Python: curried functions collapse to n-ary defs and direct calls (closures only for genuine partial application), CEs desugar to their natural Python (async/await, generators, railway Result), and units erase.
  • No CPython fork. Pyfun is a front end for the Python ecosystem, not a competing runtime.

Why Pyfun makes the choices it does is in RATIONALE.md; the full language specification is in DESIGN.md. For a guided, chapter-by-chapter walk through this pipeline in the actual source (also a good way to learn Rust on a real codebase), see Inside the compiler.


Status

MVP showcase complete and runnable: ADTs, records, tuples, computation expressions (including user-defined builders), units of measure, mutability, inferred multi-label effects, general Python FFI via extern, a module-qualified standard library, string interpolation, active patterns, typed holes, file-based modules, and a full LSP. See ROADMAP.md for what's next.

This is a solo, actively-developed project: the MVP is feature-complete and runnable, but it's pre-1.0. Expect sharp edges; the language surface is stabilizing but not frozen.


License

Pyfun is free and open source under the Apache License 2.0: use, modify, and redistribute it, including commercially. The accompanying NOTICE names Simon Treanor as the original author; keep it with any redistribution or derivative work.

Copyright ยฉ 2026 Simon Treanor.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pyfun_lang-0.3.0.tar.gz (741.6 kB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

pyfun_lang-0.3.0-py3-none-win_amd64.whl (1.1 MB view details)

Uploaded Python 3Windows x86-64

pyfun_lang-0.3.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (1.2 MB view details)

Uploaded Python 3manylinux: glibc 2.17+ x86-64

pyfun_lang-0.3.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (1.1 MB view details)

Uploaded Python 3manylinux: glibc 2.17+ ARM64

pyfun_lang-0.3.0-py3-none-macosx_11_0_arm64.whl (1.0 MB view details)

Uploaded Python 3macOS 11.0+ ARM64

pyfun_lang-0.3.0-py3-none-macosx_10_12_x86_64.whl (1.1 MB view details)

Uploaded Python 3macOS 10.12+ x86-64

File details

Details for the file pyfun_lang-0.3.0.tar.gz.

File metadata

  • Download URL: pyfun_lang-0.3.0.tar.gz
  • Upload date:
  • Size: 741.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for pyfun_lang-0.3.0.tar.gz
Algorithm Hash digest
SHA256 a7f6c15b05e97c70d33ff56c6e3d04975372a5e0ec3e77eaa9be1ddd1b909e99
MD5 4da5a2a5752140c36c139e2e101d6fc2
BLAKE2b-256 554b17c78217b8c79ab3b89cd767b1d7dbc86e67e90c83568c6d657a39ff1ad0

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyfun_lang-0.3.0.tar.gz:

Publisher: wheels.yml on simontreanor/Pyfun

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pyfun_lang-0.3.0-py3-none-win_amd64.whl.

File metadata

  • Download URL: pyfun_lang-0.3.0-py3-none-win_amd64.whl
  • Upload date:
  • Size: 1.1 MB
  • Tags: Python 3, Windows x86-64
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for pyfun_lang-0.3.0-py3-none-win_amd64.whl
Algorithm Hash digest
SHA256 207195fbbb3061cfa1d9d1f63329b2df3a7adc4e78a6c6bc47f707dfaa18961d
MD5 68fe912700a3cb99563bd046178259fd
BLAKE2b-256 862367d798604eb7a06c86110365638f5f8f9eded9911b3ea1397df50af0b37b

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyfun_lang-0.3.0-py3-none-win_amd64.whl:

Publisher: wheels.yml on simontreanor/Pyfun

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pyfun_lang-0.3.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for pyfun_lang-0.3.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 1444e49c0e34baa748ddf8be9f4625f409d017dcfe7942c9cc339ce0b5e69a55
MD5 f4cf539eeaffdd27fbaf4c47752e30f7
BLAKE2b-256 2f628abcd4167bd93d5bc46f9ff95fed3dc9d82ce4d5c5dbf924815dba0cc515

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyfun_lang-0.3.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:

Publisher: wheels.yml on simontreanor/Pyfun

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pyfun_lang-0.3.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for pyfun_lang-0.3.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 2f0312bdd5559c0d25be886a029508548d5c2834533313fef00fcfdc94252668
MD5 61583519e7c021a9afe51d739fbc415b
BLAKE2b-256 233d35a0b709c01057131e60f5c5f202532b99393757dcadafb3987d787313b6

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyfun_lang-0.3.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl:

Publisher: wheels.yml on simontreanor/Pyfun

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pyfun_lang-0.3.0-py3-none-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for pyfun_lang-0.3.0-py3-none-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 9af0a7ef4b74df7b648e33ff5b12bf1d99ed55c2ef4df29088be42c5b3f61c1f
MD5 95e7003bf88d802464d9ed02cd07251e
BLAKE2b-256 0c2828eca70e792cb9c875a69839829fc2069c95743d1547dda11bbcd75338f6

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyfun_lang-0.3.0-py3-none-macosx_11_0_arm64.whl:

Publisher: wheels.yml on simontreanor/Pyfun

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pyfun_lang-0.3.0-py3-none-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for pyfun_lang-0.3.0-py3-none-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 18647ec6b55fcda157dc810c05e0e279adfa7878fe610839aa21c5012df569f4
MD5 c2511d7b6223e8c192fedb4efb0ba3d2
BLAKE2b-256 8f3c99411fb45fd4f3ec3eb2f29887a0b3d44449e201ffce6b38cacb75e7603d

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyfun_lang-0.3.0-py3-none-macosx_10_12_x86_64.whl:

Publisher: wheels.yml on simontreanor/Pyfun

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page