Skip to content

Type Annotations⚓︎

Type annotations let a Talika schema say two things at once:

  • this is the Python shape I expect after parsing
  • this common field parser can be inferred for me

They are useful when the table value has an obvious conversion, such as int, bool, Decimal, an enum, or a small string Literal. They are not a replacement for field parsers. When the cell syntax is project-specific, the parser still needs to be explicit.

A table with typed values
Given the users exist
  | age | rating | balance | active | status    | tier  | reviewer |
  | 34  | 1.5    | 12.30   | true   | published | staff |          |

Every value in that table is authored as text. The annotations on the schema decide which fields should be converted into Python values.

Use annotations for obvious conversions

Reach for annotation inference when the Python type has one clear table meaning. If a human could reasonably ask "how is this cell written?", use an explicit parser instead.

Define an Annotated Schema⚓︎

Annotate the Python attribute and declare the field in the same line.

users_table.py
from decimal import Decimal
from enum import Enum
from typing import Literal

from talika import RowTable, field


class Status(Enum):
    DRAFT = "draft"
    PUBLISHED = "published"


class UserAnnotations(RowTable):
    age: int = field("age", required=True)
    rating: float = field("rating", required=True)
    balance: Decimal = field("balance", required=True)
    active: bool = field("active", required=True)
    status: Status = field("status", required=True)
    tier: Literal["basic", "staff"] = field("tier", required=True)
    reviewer: int | None = field("reviewer", empty="parse")

Talika reads these annotations when the schema class is created. If the annotation is supported and the field does not already have a parser, Talika attaches the matching parser to the field.

Resolution is isolated per field and follows inherited annotations back to the nearest class that declared them. If one postponed annotation cannot be resolved, only that field is left unresolved; supported annotations on other fields continue to infer their parsers. An explicit parser takes precedence without requiring its annotation to resolve.

Talika also checks the value paths it controls. A required int is consistent because its inferred parser produces an integer. An optional int must say what a blank cell means, and a field left as raw text must allow str. Explicit custom parsers and default_factory results remain trusted extension points; Talika is not a general runtime type checker.

Parse annotated values
users = UserAnnotations.parse(
    [
        ["age", "rating", "balance", "active", "status", "tier", "reviewer"],
        ["34", "1.5", "12.30", "true", "published", "staff", ""],
    ]
)

user = users[0]

assert user.age == 34
assert user.rating == 1.5
assert user.balance == Decimal("12.30")
assert user.active is True
assert user.status is Status.PUBLISHED
assert user.tier == "staff"
assert user.reviewer is None
Parsed annotated record
>> user
UserAnnotations(age=34, rating=1.5, balance=Decimal('12.30'), active=True, status=<Status.PUBLISHED: 'published'>, tier='staff', reviewer=None)

>> type(user.age), type(user.balance), type(user.status)
(<class 'int'>, <class 'decimal.Decimal'>, <enum 'Status'>)

After parsing, the record contains normal Python values. age is an int, balance is a Decimal, and status is an enum member.

Annotations belong on schema attributes

Talika only infers from attributes that are declared as fields. Annotating a random class variable does not make it part of the table contract.

Supported Scalar Annotations⚓︎

Talika intentionally supports a small set of annotations where conversion has clear local meaning:

  • int
  • float
  • bool
  • Decimal
  • Enum subclasses
  • string Literal[...]
  • simple optional unions such as int | None or Optional[int]
Scalar annotation inference
class ProductAnnotations(RowTable):
    quantity: int = field("quantity", required=True)
    weight: float = field("weight", required=True)
    price: Decimal = field("price", required=True)
    available: bool = field("available", required=True)
Parsing scalar values
product = ProductAnnotations.parse(
    [
        ["quantity", "weight", "price", "available"],
        ["3", "1.25", "19.99", "true"],
    ]
)[0]

assert product.quantity == 3
assert product.weight == 1.25
assert product.price == Decimal("19.99")
assert product.available is True

The inferred parsers behave like the corresponding parser factories. For example, int uses integer conversion, Decimal uses decimal conversion, and bool accepts only the case-insensitive default tokens true and false.

If a value cannot be converted, the error still points to the authored cell:

A boolean value outside the accepted vocabulary
ProductAnnotations.parse(
    [
        ["quantity", "weight", "price", "available"],
        ["3", "1.25", "19.99", "maybe"],
    ]
)
Inferred boolean parser failure
Field parser failed: Expected one of ['false', 'true']
(code=parser_failed, schema=ProductAnnotations, field='available', 
row=2, column=1, value='maybe'). 
Hint: Check the cell value or adjust the field parser for this syntax.

Boolean inference is strict

A bool annotation does not use Python truthiness. Values such as "maybe", "yes", or "disabled" fail unless you provide an explicit boolean(true_values=..., false_values=...) parser with that vocabulary.

Enums and Literal Values⚓︎

Enums are useful when a table value should become a real domain value in test code.

Enum and Literal annotations
class ArticleStatus(Enum):
    DRAFT = "draft"
    PUBLISHED = "published"


class ArticleAnnotations(RowTable):
    status: ArticleStatus = field("status", required=True)
    tier: Literal["basic", "staff"] = field("tier", required=True)

For enum annotations, Talika accepts either the enum value or the enum member name. That lets feature files use readable domain text while tests can still compare against enum members.

Under the hood, Talika's inferred enum parser operates as follows:

  1. Converts the table cell value into a string (raw = str(value)).
  2. Iterates through the enum members and checks if str(member.value) matches raw, or if the member name member.name matches raw.
  3. Returns the matching enum member when found.
  4. If no member matches, it raises a ValueError containing a list of the expected enum values.
Parsing enum values
articles = ArticleAnnotations.parse(
    [
        ["status", "tier"],
        ["published", "staff"],
        ["DRAFT", "basic"],
    ]
)

assert articles[0].status is ArticleStatus.PUBLISHED
assert articles[1].status is ArticleStatus.DRAFT

Literal[...] is narrower. It validates that the cell is exactly one of the declared strings and returns the string itself.

A Literal value outside the allowed set
ArticleAnnotations.parse(
    [
        ["status", "tier"],
        ["draft", "premium"],
    ]
)
Literal parser failure
Field parser failed: Expected one of ['basic', 'staff'] 
(code=parser_failed, schema=ArticleAnnotations, field='tier', 
row=2, column=1, value='premium'). 
Hint: Check the cell value or adjust the field parser for this syntax.

Literal matching is exact

Literal["basic", "staff"] does not strip whitespace, change case, or map synonyms. If authors may write Staff, employee, or staff member, use an explicit parser that describes that vocabulary.

Optional Annotations⚓︎

Use an optional annotation when missing data may become None. Add empty="parse" when a present blank or null-like token should also reach the inferred optional parser and become None.

Optional annotation inference
class ReviewAnnotations(RowTable):
    reviewer_id: int | None = field("reviewer id", empty="parse")
    backup_owner: str | None = field("backup owner", empty="parse")
Parsing optional values
reviews = ReviewAnnotations.parse(
    [
        ["reviewer id", "backup owner"],
        ["", "none"],
        ["42", "Priya"],
    ]
)

assert reviews[0].reviewer_id is None
assert reviews[0].backup_owner is None
assert reviews[1].reviewer_id == 42
assert reviews[1].backup_owner == "Priya"
Optional annotation results
>> reviews[0].as_dict()
{'reviewer_id': None, 'backup_owner': None}

>> reviews[1].as_dict()
{'reviewer_id': 42, 'backup_owner': 'Priya'}

For int | None, a non-empty value is parsed as an integer. With empty="parse", a blank cell, none, or null becomes None. For str | None, non-null values remain strings while the same null-like tokens become None.

This is different from a plain optional field with no annotation. A plain field can be absent and return None, but an explicit blank cell normally remains "" unless the field parser or empty-cell policy says otherwise.

Make blank parsing visible

The optional parser understands blank input, but receives it only when the field says empty="parse". This keeps the empty-cell contract explicit.

Lists Need Explicit Parsers⚓︎

Talika does not infer list syntax from list[str] or list[int].

List annotations without parsers
class TagAnnotations(RowTable):
    tags: list[str] = field("tags", required=True)

The reason is practical: a table cell can describe a list in many ways. It might use commas, pipes, semicolons, one value per line, JSON text, or project-specific tokens. Talika should not guess that language from the Python annotation alone.

Schema definition failure
SchemaDefinitionError: Field 'tags' has no parser and would remain text, but its
annotation does not accept raw str values; add an explicit parser or use a
supported annotation (schema=TagAnnotations)
Correction
Use an explicit parser when the annotation describes a project-owned result.

If a cell should become a list, say how to split and parse it:

List annotations with explicit parsers
from talika import compose, each, integer, split


class ExplicitListAnnotations(RowTable):
    tags: list[str] = field("tags", required=True, parser=split(","))
    scores: list[int] = field(
        "scores", required=True, parser=compose(split(","), each(integer()))
    )
Parsing explicit list syntax
record = ExplicitListAnnotations.parse(
    [
        ["tags", "scores"],
        ["qa, docs", "1, 2"],
    ]
)[0]

assert record.tags == ["qa", "docs"]
assert record.scores == [1, 2]
Parsed list values
>> record.as_dict()
{'tags': ['qa', 'docs'], 'scores': [1, 2]}

Here the annotation documents the result, while the parser describes the cell syntax. Both are useful, but they answer different questions.

Do not expect containers to parse themselves

list[str] tells Python readers what the field should become. It does not tell Talika whether qa, docs, qa|docs, or ["qa", "docs"] is the intended authored syntax.

Explicit Parsers Win⚓︎

If a field declares parser=..., Talika uses that parser and ignores annotation inference for that field.

Explicit parser overrides annotation inference
from talika import string


class OverrideAnnotations(RowTable):
    code: int = field("code", required=True, parser=string(upper=True))
The parser decides the result
record = OverrideAnnotations.parse(
    [
        ["code"],
        ["many"],
    ]
)[0]

assert record.code == "MANY"

The field is annotated as int, but the explicit parser returns "MANY". Talika does not add another integer conversion after your parser runs.

This is useful when the annotation describes the eventual domain expectation, but the authored table uses a vocabulary that needs custom normalization.

Annotation inference is not enforcement after parsing

Talika does not check that an explicit parser returns the annotated type. If you write a parser that returns a string from a field annotated as int, the parser result is what the record receives.

Unsupported Annotations Need Parsers⚓︎

When Talika does not know how to infer a safe parser and the annotation does not accept text, schema creation fails with a concrete correction. This avoids returning a string from a field that promises a custom class or ambiguous union.

Unsupported annotations
class InternalId:
    def __init__(self, value):
        self.value = value


class UnsupportedAnnotations(RowTable):
    value: InternalId = field(
        "value", required=True, parser=lambda value, context: InternalId(value)
    )
    mixed: int | float = field(
        "mixed", required=True, parser=lambda value, context: float(value)
    )
Parsing unsupported annotations
record = UnsupportedAnnotations.parse(
    [
        ["value", "mixed"],
        ["raw-id", "12.5"],
    ]
)[0]

assert record.value.value == "raw-id"
assert record.mixed == 12.5

This includes custom classes and ambiguous unions such as int | float. Both types could plausibly parse the same value, and choosing one would be a hidden policy decision. The example supplies explicit parsers for both fields.

A useful rule of thumb

If an annotation names a domain concept, Talika probably cannot know how to construct it from one table cell. Keep the annotation if it helps readers, and add an explicit parser when the schema should build that value.

Errors from Inferred Parsers⚓︎

Inferred parsers fail the same way explicit parsers fail: Talika wraps the parser exception in a table-aware diagnostic.

An integer annotation parsing bad text
UserAnnotations.parse(
    [
        ["age"],
        ["many"],
    ]
)
Inferred integer parser failure
Field parser failed: invalid literal for int() with base 10: 'many' 
(code=parser_failed, schema=UserAnnotations, field='age', 
row=2, column=1, value='many'). 
Hint: Check the cell value or adjust the field parser for this syntax.

The diagnostic names the schema, field, row, column, and original value. That keeps annotation inference from hiding where the authored table needs to be fixed.

Type annotations are best when they make a schema quieter without making table syntax mysterious. Let them remove repeated parser declarations for common types, and stay explicit whenever the table language belongs to your project.