Full documentation is hosted on Read the Docs at https://python-typewriter.readthedocs.io/en/latest/. The hosted site publishes the default branch as latest and tagged releases as versioned docs.
Typewriter is a Python Typer CLI built on LibCST that normalizes None-related type annotations in Python source code. It can be used to automatically rewrite type annotations to use Optional instead of Union when None is involved, and to ensure Optional is used for variable and parameter annotations when the default value is None.
What it rewrites (default mode, Python 3.9-compatible):
Union[T, None] -> Optional[T]
Union[T1, T2, None] -> Optional[Union[T1, T2]]
x: T = None -> x: Optional[T] = None
def f(x: T = None) -> def f(x: Optional[T] = None)With --target-version 3.10 (PEP 604 mode):
Union[T, None] -> T | None
Optional[T] -> T | None
Union[T1, T2] -> T1 | T2
Union[T1, T2, None] -> T1 | T2 | None
x: T = None -> x: T | None = None
def f(x: T = None) -> def f(x: T | None = None)Additional notes:
x: Any = Nonestays unchanged.- Qualified typing references are preserved.
- Import statements are added as needed and deduplicated.
- Unused
UnionandOptionalimports are cleaned up after rewriting.
pip install py-typewriter-cli # Python 3.10-3.13Typewriter exposes one subcommand: run.
Consider example.py with contents:
from typing import Any, Union
count: int = None
name: str = None
payload: Any = None
def greet(user_id: int = None, msg: Union[str, None] = None) -> Union[str, None]:
if user_id is None:
return None
return f"hello-{user_id}\n{msg}"To see what would change without writing files, run with --check:
typewriter run path/to/example.py --checkWould transform path/to/example.py
--- path/to/example.py
+++ path/to/example.py
@@ ...
-old line
+new line
In --check mode, Typewriter prints a unified diff of its proposed changes to stdout and provides the following Exit codes:
0: no changes needed1: changes would be made2: error
To apply the detected changes, run without --check:
typewriter run path/to/example.pyTo run recursively on all *.py files in a directory:
typewriter run examples # Non-`.py` files are rejected.To transform an in-memory string and return the result to stdout, use --code:
typewriter run --code "var: int = None\\n"To emit T | None style unions instead of Optional[T], pass --target-version 3.10 (or any Python 3.10+ version):
typewriter run path/to/example.py --target-version 3.10In this mode, existing Optional[...] and Union[...] annotations are normalized to
PEP 604 syntax too.
The default output remains Python 3.9-compatible (Optional[T]).
To skip additional files or directories by glob pattern, use --ignore (repeatable):
typewriter run myproject --ignore "test_*" --ignore "generated"Patterns are matched against both the bare file/directory name and the relative
path from the scanned root. The built-in skip set (.git, .venv, __pycache__,
build, dist, etc.) is always active regardless of extra patterns.
To also honor the nearest .gitignore at or above the scanned directory:
typewriter run myproject --respect-gitignorePATHand--codeare mutually exclusive.- Literal
\\nsequences in--codeinput are interpreted as newlines. - When a directory is provided as
PATH, Typewriter will ignore non-.pyfiles and common non-source subdirectories such as.git,.venv,venv,__pycache__,build, anddist. - PEP 604 syntax (
T | None) is opt-in via--target-version 3.10and is not used by default.
Use --check in CI when you want Typewriter to fail the job if a pull request still
contains None-related typing rewrites that should be applied:
typewriter run . --check --respect-gitignoreExit codes in CI-friendly --check mode are:
0: the scanned files are already normalized1: Typewriter found rewrites it would apply2: Typewriter hit an error
For bots and automation, switch to machine-readable output:
typewriter run . --check --respect-gitignore --output-format jsonA minimal GitHub Actions step looks like this:
- name: Check None-related typing annotations
run: typewriter run . --check --respect-gitignoreTypewriter currently accepts a single path per invocation, so the simplest pre-commit integration is a local hook that scans the repository root:
repos:
- repo: local
hooks:
- id: typewriter
name: typewriter
entry: typewriter run .
language: system
pass_filenames: falseThat hook applies changes in place. Pair it with a CI --check run so contributors see
the same normalization rules locally and in pull requests.
typewriter run . --checkpreviews diffs, leaves files untouched, and exits with1when changes are needed.typewriter run .applies the same rewrites in place and exits with0when it completes successfully.
Use --check in CI, editor integrations, and review bots. Use apply mode in local
cleanup workflows or scripted codemod runs.
Use --ignore for paths that should always stay out of scope for a Typewriter run:
typewriter run . --check --ignore "generated" --ignore "test_*"Those patterns are matched against both bare names and paths relative to the scanned
directory. --respect-gitignore is complementary: it applies the nearest .gitignore
at or above the scanned directory, which is helpful in monorepos and CI jobs that scan
from a package subdirectory.
By default, Typewriter emits Optional[...] so the output stays compatible with Python
3.9 codebases. Use --target-version 3.10 (or any 3.10+ target) when your project is
already standardized on PEP 604 unions and you want Typewriter to normalize existing
Optional[...] and Union[...] annotations to T | None as well:
typewriter run path/to/example.py --target-version 3.10Typewriter intentionally focuses on one narrow kind of codemod: normalizing
None-related type annotations.
It does not try to:
- perform broad typing cleanup outside the current
None-focused rewrite rules, - rewrite docstrings or narrative documentation,
- infer business meaning beyond the syntax-driven codemod rules,
- replace a full linting, formatting, or static-analysis workflow.
If you need larger typing migrations, use Typewriter as one step in a broader toolchain instead of expecting it to infer project-wide intent.
The package version is the single source of truth. The documentation version in
docs/source/conf.py is derived automatically from typewriter.__version__, which
reads the distribution metadata for py-typewriter-cli.
Typewriter was created to resolve issue #2303 in microsoft/playwright-python.
The issue highlighted a common inconsistency in the codebase where type annotations involving None were not standardized, leading to confusion and maintenance challenges. Specifically, some type annotations used Union[T, None] while others used Optional[T], and there were cases where variable and parameter annotations did not use Optional even when the default value was None. This made it difficult to maintain a consistent style and to leverage static analysis tools effectively.
Surely, other developers have encountered similar inconsistencies in their codebases, and Typewriter can be a useful tool for anyone looking to standardize a common inconsistency and correct a common issue related to the use of None in type annotations. By using Typewriter,
developers can ensure that their codebase adheres to PEP 484 proposal of requiring optional types to be made explicit.
Contributions to Typewriter are welcome! Please follow the fork-and-pull request workflow:
- Fork the repository and create a new branch for your feature or bug fix.
- Make your changes and commit them with clear messages.
- Push your branch to your fork and open a pull request against the
mainbranch of the original repository.