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.
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.
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.
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
>> 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:
intfloatboolDecimalEnumsubclasses- string
Literal[...] - simple optional unions such as
int | NoneorOptional[int]
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)
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:
ProductAnnotations.parse(
[
["quantity", "weight", "price", "available"],
["3", "1.25", "19.99", "maybe"],
]
)
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.
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:
- Converts the table cell value into a string (
raw = str(value)). - Iterates through the enum members and checks if
str(member.value)matchesraw, or if the member namemember.namematchesraw. - Returns the matching enum member when found.
- If no member matches, it raises a
ValueErrorcontaining a list of the expected 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.
ArticleAnnotations.parse(
[
["status", "tier"],
["draft", "premium"],
]
)
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.
class ReviewAnnotations(RowTable):
reviewer_id: int | None = field("reviewer id", empty="parse")
backup_owner: str | None = field("backup owner", empty="parse")
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"
>> 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].
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.
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)
If a cell should become a list, say how to split and parse it:
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()))
)
record = ExplicitListAnnotations.parse(
[
["tags", "scores"],
["qa, docs", "1, 2"],
]
)[0]
assert record.tags == ["qa", "docs"]
assert record.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.
from talika import string
class OverrideAnnotations(RowTable):
code: int = field("code", required=True, parser=string(upper=True))
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.
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)
)
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.
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.