Explicit parameter declarations

Litestar embraces and enforces explicit declarations of handler / dependency parameters. This articles aims to explain the motivations behind this decision, and address common questions and objections regarding its downsides.

What? Explicit declarations?

Simply put, in Litestar, an explicit parameter declaration is any handler or dependency function parameter, marked with a marker such as FromQuery, e.g.

@get("/")
async def handler(name: FromQuery[str]) -> str:
    return name

See also

Parameters

Historical background

Previous versions of Litestar allowed implicit parameters, i.e. parameter sources were decided via inference. Specifically, this was possible for path, query, and dependency parameters.

Declaring a query parameter ‘message’
@get("/")
async def handler(message: str) -> str:
    return message

Here, because message is not “consumed” by other sources, would implicitly become a query parameter.

This was influenced by the design of other frameworks such as FastAPI or DRF, and aimed to provide convenience when writing.

However, it gets tricky quickly. For example, if the same route handler would be included in a router with a path parameter with the name message, the path parameter would win, and message wouldn’t be a query parameter anymore.

This means that the inferred parameter source is context dependant.

There are upsides to this, for example, one could argue that this is a practical application of the Inversion of Control pattern via dependency injection; The callee declaring the parameter does not need to know anything about the context it’s being called from, it simply declares its dependencies, leaving it up to the caller how to provide them.

This though still holds true with explicit declarations: The callee simply declares additional information, i.e. the kind of parameter it requires, which in most cases is not a purely cosmetic difference, but carries meaning: An x-api-key header is not equivalent to a query parameter of the same name.

Motivation

Easy to read over easy to write

Spelling out additional symbols for each declaration is more typing work, there’s no arguing with that. Since “code is read much more often than it is written”, and most of a developer’s time is spent understanding and reasoning about code [1], it should follow that a library should first and foremost be easy to comprehend.

Therefore, when designing Litestar’s explicit parameter declarations, the goal was that to design them in such a way that “any reader, even if unfamiliar with the framework can understand their meaning looking at the declarations alone and perhaps their docstrings for nuance”.

Easy to write

This is easy to write!
@get("/")
async def handler(
    data: dict[str, str],
    limit: int,
    page: Annotated[int, Parameter(ge=1)],
    db_session: AsyncSession,
) -> None:
    ...

This is easy to write, or rather, easy to type. However, there’s not much information available to a reader. Where do data and all the other parameters come from? One could infer from limit and page that they’re probably query parameters, but as discussed in Historical background, that could also be context dependant.

Easy to read

This is easy to read
@get("/")
async def handler(
    data: JSONBody[dict[str, str]],
    limit: Annotated[int, QueryParameter()],
    page: Annotated[int, QueryParameter(ge=1)],
    db_session: NamedDependency[AsyncSession],
) -> None:
    ...

This is much more verbose, but it’s also packed with information:

  • data: JSONBody[dict[str, str]] tells us:
    • This is the request body

    • The request body is in JSON

  • limit: Annotated[int, QueryParameter()]: This is a query parameter

  • page: Annotated[int, QueryParameter(ge=1)]: Another query parameter

  • db_session: NamedDependency[AsyncSession] It’s a dependency with a name

Avoiding errors

An explicit approach brings the significant benefit of being able to fail early; If a parameter has no declared source, Litestar can immediately raise an error about this, preventing time consuming runtime debugging.

For example, a very common, and sometimes surprising, error with the inference based approach was a missing required query parameter 'xxx', if a dependency was not provided. Why? Because whether a parameter is interpreted as a dependency or a query parameter depends on the presence of a dependency provider. If no provider is present, the parameter would get interpreted as a query parameter, and, if given no default, marked as required.

@get("/")
async def get_page(db_session: AsyncSession) -> None:
    ...

router_a = Router("/a", [get_page], dependencies={"db_session": provide_session})
router_b = Router("/b", [get_page])

Calling /a would work as expected, but calling /b would result in a 400 - Bad Request with the info missing required query parameter 'db_session'.

@get("/")
async def get_page(db_session: NamedDependency[AsyncSession]) -> None:
    ...

router_a = Router("/a", [get_page], dependencies={"db_session": provide_session})
router_b = Router("/b", [get_page])

With explicit declarations, these can either be detected at startup time, or a specific and correct error can be raised; In this case it would be a 500 - Internal Server Error, including a more helpful error message in stderr: Explicit dependency 'db_session' for 'get_page' has no default value, or provided dependency.

Documenting intent

When using implicit declarations, the intent is also implicitly documented, i.e. not. Intent not expressed by the code usually goes one of two ways: Either it is documented in comments, or eventually forgotten. Through the use of explicit declarations, intent is always encoded right at the site of use, and not separate comment needs to be added and kept up to date.