diff --git a/docs/tutorial/parameter-types/dict.md b/docs/tutorial/parameter-types/dict.md
new file mode 100644
index 0000000000..0fae3db467
--- /dev/null
+++ b/docs/tutorial/parameter-types/dict.md
@@ -0,0 +1,28 @@
+# Dict
+
+You can declare a *CLI parameter* to be a standard Python `dict`:
+
+{* docs_src/parameter_types/dict/tutorial001_an.py hl[5] *}
+
+Check it:
+
+
+
+```console
+// Run your program
+$ python main.py --user-info '{"name": "Camila", "age": 15, "height": 1.7, "female": true}'
+
+Name: Camila
+User attributes: ['age', 'female', 'height', 'name']
+
+```
+
+
+
+This can be particularly useful when you want to include JSON input:
+
+```python
+import json
+
+data = json.loads(user_input)
+```
diff --git a/docs_src/parameter_types/dict/__init__.py b/docs_src/parameter_types/dict/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/docs_src/parameter_types/dict/tutorial001.py b/docs_src/parameter_types/dict/tutorial001.py
new file mode 100644
index 0000000000..2f0cd61559
--- /dev/null
+++ b/docs_src/parameter_types/dict/tutorial001.py
@@ -0,0 +1,10 @@
+import typer
+
+
+def main(user_info: dict = typer.Option()):
+ print(f"Name: {user_info.get('name', 'Unknown')}")
+ print(f"User attributes: {sorted(user_info.keys())}")
+
+
+if __name__ == "__main__":
+ typer.run(main)
diff --git a/docs_src/parameter_types/dict/tutorial001_an.py b/docs_src/parameter_types/dict/tutorial001_an.py
new file mode 100644
index 0000000000..92b507a322
--- /dev/null
+++ b/docs_src/parameter_types/dict/tutorial001_an.py
@@ -0,0 +1,11 @@
+import typer
+from typing_extensions import Annotated
+
+
+def main(user_info: Annotated[dict, typer.Option()]):
+ print(f"Name: {user_info.get('name', 'Unknown')}")
+ print(f"User attributes: {sorted(user_info.keys())}")
+
+
+if __name__ == "__main__":
+ typer.run(main)
diff --git a/tests/test_others.py b/tests/test_others.py
index 1078e63d1f..3499188cf6 100644
--- a/tests/test_others.py
+++ b/tests/test_others.py
@@ -1,3 +1,4 @@
+import json
import os
import subprocess
import sys
@@ -12,7 +13,7 @@
import typer.completion
from typer.core import _split_opt
from typer.main import solve_typer_info_defaults, solve_typer_info_help
-from typer.models import ParameterInfo, TyperInfo
+from typer.models import DictParamType, ParameterInfo, TyperInfo
from typer.testing import CliRunner
from .utils import requires_completion_permission
@@ -278,3 +279,19 @@ def test_split_opt():
prefix, opt = _split_opt("verbose")
assert prefix == ""
assert opt == "verbose"
+
+
+def test_json_param_type_convert():
+ data = {"name": "Camila", "age": 15, "height_meters": 1.7, "female": True}
+ converted = DictParamType().convert(json.dumps(data), None, None)
+ assert data == converted
+
+
+def test_json_param_type_convert_dict_input():
+ data = {"name": "Camila", "age": 15, "height_meters": 1.7, "female": True}
+ converted = DictParamType().convert(data, None, None)
+ assert data == converted
+
+
+def test_dict_param_tyoe_name():
+ assert repr(DictParamType()) == "DICT"
diff --git a/tests/test_tutorial/test_parameter_types/test_dict/__init__.py b/tests/test_tutorial/test_parameter_types/test_dict/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/test_tutorial/test_parameter_types/test_dict/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_dict/test_tutorial001.py
new file mode 100644
index 0000000000..e91dc73d19
--- /dev/null
+++ b/tests/test_tutorial/test_parameter_types/test_dict/test_tutorial001.py
@@ -0,0 +1,49 @@
+import json
+import subprocess
+import sys
+
+import typer
+from typer.testing import CliRunner
+
+from docs_src.parameter_types.dict import tutorial001 as mod
+
+runner = CliRunner()
+
+app = typer.Typer()
+app.command()(mod.main)
+
+
+def test_help():
+ result = runner.invoke(app, ["--help"])
+ assert result.exit_code == 0
+ assert "--user-info" in result.output
+ assert "DICT" in result.output
+
+
+def test_params():
+ data = {"name": "Camila", "age": 15, "height": 1.7, "female": True}
+ result = runner.invoke(
+ app,
+ [
+ "--user-info",
+ json.dumps(data),
+ ],
+ )
+ assert result.exit_code == 0
+ assert "Name: Camila" in result.output
+ assert "User attributes: ['age', 'female', 'height', 'name']" in result.output
+
+
+def test_invalid():
+ result = runner.invoke(app, ["--user-info", "Camila"])
+ assert result.exit_code != 0
+ assert "Expecting value: line 1 column 1 (char 0)" in result.exc_info[1].args[0]
+
+
+def test_script():
+ result = subprocess.run(
+ [sys.executable, "-m", "coverage", "run", mod.__file__, "--help"],
+ capture_output=True,
+ encoding="utf-8",
+ )
+ assert "Usage" in result.stdout
diff --git a/tests/test_tutorial/test_parameter_types/test_dict/test_tutorial001_an.py b/tests/test_tutorial/test_parameter_types/test_dict/test_tutorial001_an.py
new file mode 100644
index 0000000000..8e7e06a719
--- /dev/null
+++ b/tests/test_tutorial/test_parameter_types/test_dict/test_tutorial001_an.py
@@ -0,0 +1,49 @@
+import json
+import subprocess
+import sys
+
+import typer
+from typer.testing import CliRunner
+
+from docs_src.parameter_types.dict import tutorial001_an as mod
+
+runner = CliRunner()
+
+app = typer.Typer()
+app.command()(mod.main)
+
+
+def test_help():
+ result = runner.invoke(app, ["--help"])
+ assert result.exit_code == 0
+ assert "--user-info" in result.output
+ assert "DICT" in result.output
+
+
+def test_params():
+ data = {"name": "Camila", "age": 15, "height": 1.7, "female": True}
+ result = runner.invoke(
+ app,
+ [
+ "--user-info",
+ json.dumps(data),
+ ],
+ )
+ assert result.exit_code == 0
+ assert "Name: Camila" in result.output
+ assert "User attributes: ['age', 'female', 'height', 'name']" in result.output
+
+
+def test_invalid():
+ result = runner.invoke(app, ["--user-info", "Camila"])
+ assert result.exit_code != 0
+ assert "Expecting value: line 1 column 1 (char 0)" in result.exc_info[1].args[0]
+
+
+def test_script():
+ result = subprocess.run(
+ [sys.executable, "-m", "coverage", "run", mod.__file__, "--help"],
+ capture_output=True,
+ encoding="utf-8",
+ )
+ assert "Usage" in result.stdout
diff --git a/typer/main.py b/typer/main.py
index 36737e49ef..e746c27b5a 100644
--- a/typer/main.py
+++ b/typer/main.py
@@ -35,6 +35,7 @@
Default,
DefaultPlaceholder,
DeveloperExceptionConfig,
+ DictParamType,
FileBinaryRead,
FileBinaryWrite,
FileText,
@@ -710,8 +711,10 @@ def get_click_type(
elif parameter_info.parser is not None:
return click.types.FuncParamType(parameter_info.parser)
- elif annotation is str:
+ elif annotation in [str, bytes]:
return click.STRING
+ elif annotation is dict:
+ return DictParamType()
elif annotation is int:
if parameter_info.min is not None or parameter_info.max is not None:
min_ = None
diff --git a/typer/models.py b/typer/models.py
index 544e504761..37e5933a60 100644
--- a/typer/models.py
+++ b/typer/models.py
@@ -1,5 +1,6 @@
import inspect
import io
+import json
from typing import (
TYPE_CHECKING,
Any,
@@ -20,7 +21,6 @@
from .core import TyperCommand, TyperGroup
from .main import Typer
-
NoneType = type(None)
AnyType = Type[Any]
@@ -52,6 +52,23 @@ class CallbackParam(click.Parameter):
pass
+class DictParamType(click.ParamType):
+ name = "dict"
+
+ def convert(
+ self,
+ value: Any,
+ param: Optional["click.Parameter"],
+ ctx: Optional["click.Context"],
+ ) -> Any:
+ if isinstance(value, dict):
+ return value
+ return json.loads(value)
+
+ def __repr__(self) -> str:
+ return "DICT"
+
+
class DefaultPlaceholder:
"""
You shouldn't use this class directly.