Variants⚓︎
Use variants when one table contains records from the same family, but not every record has the same fields.
CMS content is a common example. Every item may have an ID, type, and headline. An article needs a body. A poll needs options. A video needs a URL. These records belong together in one authored setup table, but each content type has its own required fields and parsed shape.
Feature: CMS content variants
Scenario: Parse articles and polls from one table
Given the content items:
| IDs | A-1 | P-1 |
| Type | Article | Poll |
| Headline | Market brief | Reader question |
| Body | Full text | |
| Options | | Yes, No |
Then each item uses the fields for its content type
Without variants, the schema has an awkward choice: make every possible field optional, or split one readable table into several separate tables. Variants let the table stay together while each record is parsed by the schema that matches its discriminator value.
Think one table family
A variant table has shared fields on the base schema and specific fields on each selected variant. The discriminator cell chooses which specific schema applies to each record.
Use TableFields for Concise Variants⚓︎
The concise style uses TableFields components and a discriminator(...)
field with a variant mapping.
from dataclasses import dataclass
from talika import (
ColumnTable,
RowTable,
TableFields,
discriminator,
discriminator_field,
field,
id_field,
integer,
split,
)
class ArticleFields(TableFields):
body = field("Body", required=True)
class PollFields(TableFields):
options = field("Options", required=True, parser=split(","))
class ContentTable(ColumnTable):
id = id_field("IDs")
content_type = discriminator(
"Type",
variants={"Article": ArticleFields, "Poll": PollFields},
)
headline = field("Headline", required=True)
The base table declares fields that every content item has:
idcontent_typeheadline
The variant components declare fields that only apply to one selected content type:
ArticleFieldsdeclaresbodyPollFieldsdeclaresoptions
content_table = [
["IDs", "A-1", "P-1"],
["Type", "Article", "Poll"],
["Headline", "Market brief", "Reader question"],
["Body", "Full text", ""],
["Options", "", "Yes, No"],
]
When Talika parses a record, it reads the Type cell, selects the matching
variant schema, and then parses only the fields that apply to that schema.
article, poll = ContentTable.parse(content_table)
assert isinstance(article, ContentTable)
assert isinstance(article, ArticleFields)
assert article.body == "Full text"
assert isinstance(poll, ContentTable)
assert isinstance(poll, PollFields)
assert poll.options == ["Yes", "No"]
>> article.id
'A-1'
>> article.content_type
'Article'
>> article.body
'Full text'
>> poll.options
['Yes', 'No']
Generated variant records are instances of the base table and the selected
component. That is why article is both a ContentTable record and an
ArticleFields record.
TableFields components do not parse alone
TableFields is a reusable group of declarations. It becomes parseable
only when Talika composes it with a concrete RowTable or ColumnTable
through a discriminator mapping.
Ask for a Variant Schema⚓︎
Declarative variants generate concrete schema classes. Use variant_for(...)
when code needs the generated class for assertions, introspection, or type
checks.
article_schema = ContentTable.variant_for("Article")
poll_schema = ContentTable.variant_for("Poll")
assert isinstance(article, article_schema)
assert isinstance(poll, poll_schema)
The generated class name is an implementation detail. The stable lookup is the
registered discriminator value, such as "Article" or "Poll".
Use variant_for instead of class-name guessing
Generated classes have readable names, but user code should not depend on
those names. Use ContentTable.variant_for("Article") when you need the
schema selected for that value.
Use Explicit Variant Classes When Names Matter⚓︎
The explicit style uses discriminator_field(...) on the base schema and
registers concrete subclasses with @Table.variant(value).
class ExplicitContentTable(ColumnTable):
id = id_field("IDs")
content_type = discriminator_field("Type")
headline = field("Headline", required=True)
@ExplicitContentTable.variant("Article")
class ArticleContent(ExplicitContentTable):
body = field("Body", required=True)
@ExplicitContentTable.variant("Video")
class VideoContent(ExplicitContentTable):
url = field("URL", required=True)
This style is useful when variants need their own class names, methods, validators, custom output builders, or direct imports from project code.
article, video = ExplicitContentTable.parse(
[
["IDs", "A-1", "V-1"],
["Type", "Article", "Video"],
["Headline", "Market brief", "Launch clip"],
["Body", "Full text", ""],
["URL", "", "/launch-video"],
]
)
>> type(article).__name__
'ArticleContent'
>> article.body
'Full text'
>> type(video).__name__
'VideoContent'
>> video.url
'/launch-video'
Both styles use the same parser lifecycle. The difference is how the variant schemas are declared.
Use discriminator(..., variants={...}) when a compact table family is enough.
Use explicit subclasses when the variant class itself is part of your test or
domain code.
Register parsed discriminator values
If the discriminator has a parser, variant keys must match the parsed values, not the raw table text.
Register variants before parsing
Explicit @Table.variant(...) decorators should run while the schema
module is imported. The first successful schema-family finalization by
parse(), parse_as(), or validate() seals the registry. Registering another
variant after that raises SchemaDefinitionError. describe() and
variant_for() inspect the current registry without sealing it.
__variants__ is a read-only compatibility view. Always use the decorator
rather than mutating that mapping directly.
Unknown Variants Point to the Discriminator⚓︎
When the discriminator value is not registered, Talika points at the cell that selected the unknown variant.
ContentTable.parse(
[
["IDs", "X-1"],
["Type", "Video"],
["Headline", "Clip"],
["Body", ""],
["Options", ""],
]
)
Unknown variant 'Video'; expected one of: 'Article', 'Poll' (code=unknown_variant, schema=ContentTable, field='Type', row=2, column=2, item_id='X-1', value='Video'). Hint: Use a discriminator value registered on this schema.
The diagnostic includes the allowed values because the fix is usually to change the authored discriminator cell or register another variant schema.
The discriminator is required
A variant table must have one discriminator field. Talika cannot choose a record schema without it.
Required Fields Belong to the Selected Variant⚓︎
Variant fields are checked only when their variant is selected.
An article needs Body. A poll does not. A poll needs Options. An article
does not. Empty cells for other variants are allowed so one table can show the
union of all possible fields.
ContentTable.parse(
[
["IDs", "A-1"],
["Type", "Article"],
["Headline", "Market brief"],
]
)
Required field is missing from the table (code=missing_required, schema=ContentTable[Article], field='Body', item_id='A-1'). Hint: Add this field to the table, or make the schema field optional if the project should supply it.
The schema name is ContentTable[Article], not just ContentTable, because
the missing field belongs to the selected article variant.
Keep shared fields on the base schema
If every variant needs a field, declare it on the base table. If only one variant needs it, declare it on that variant.
Non-Empty Wrong-Variant Fields Are Rejected⚓︎
Variant tables often include rows or columns for every possible variant field. That is fine when the cells are empty for records where the field does not apply.
A non-empty value for the wrong variant usually means the table author put data in the wrong place.
ContentTable.parse(
[
["IDs", "P-1"],
["Type", "Poll"],
["Headline", "Reader question"],
["Body", "Unexpected article text"],
["Options", "Yes, No"],
]
)
Field does not apply to variant 'Poll' (code=inapplicable_field, schema=ContentTable[Poll], field='Body', row=4, column=2, item_id='P-1', value='Unexpected article text'). Hint: Move this value to a record with the matching variant, leave the cell empty, or change inapplicable_fields policy.
This strict behavior prevents quiet mistakes. If the selected record is a
Poll, a non-empty Body cell is suspicious because Body belongs to
Article.
Empty wrong-variant cells are different
Empty cells for other variants are ignored. Non-empty cells for other variants are rejected by default because they look like misplaced data.
Parse the Discriminator When Needed⚓︎
The discriminator can have a parser. Talika runs that parser before looking up the variant.
class NormalizedContentTable(RowTable):
content_type = discriminator_field(
"type",
parser=lambda value, context: value.casefold(),
)
@NormalizedContentTable.variant("article")
class NormalizedArticle(NormalizedContentTable):
body = field("body")
>> NormalizedContentTable.parse([["type", "body"], ["ARTICLE", "News"]])
[NormalizedArticle(content_type='article', body='News')]
The table text contains "ARTICLE", but the discriminator parser changes it
to "article". The registered variant key must therefore be "article".
Use this for case normalization, enum conversion, or project vocabulary where the authored table text should be accepted in more than one spelling.
Variant keys are Python values
Registered variant keys are not limited to strings. If the discriminator parser returns an enum member or another hashable value, register that parsed value.
Give Each Variant Its Own Output Model⚓︎
Variants can define their own output_model or build_output() behavior.
That is useful when parse_as() should return different project object types
from one table.
@dataclass(frozen=True)
class Article:
content_type: str
headline: str
body: str
@dataclass(frozen=True)
class Poll:
content_type: str
headline: str
options: list[str]
class OutputContentTable(RowTable):
content_type = discriminator_field("type")
headline = field("headline", required=True)
@OutputContentTable.variant("Article")
class OutputArticle(OutputContentTable):
output_model = Article
body = field("body", required=True)
@OutputContentTable.variant("Poll")
class OutputPoll(OutputContentTable):
output_model = Poll
options = field("options", required=True, parser=split(","))
>> OutputContentTable.parse_as(
... [
... ["type", "headline", "body", "options"],
... ["Article", "News", "Text", ""],
... ["Poll", "Choose", "", "Yes, No"],
... ]
... )
[Article(content_type='Article', headline='News', body='Text'), Poll(content_type='Poll', headline='Choose', options=['Yes', 'No'])]
The article row becomes an Article object. The poll row becomes a Poll
object. The base table still owns the shared parsing contract, and each
variant owns the output shape for its selected records.
Keep conversion on the variant that owns the shape
If variants have different output types, define output conversion on the variant classes or components. The base table should only handle output that is common to every record.
Variants Work In Row Tables Too⚓︎
Column tables are a natural fit for CMS content cards, but variants are not limited to column-shaped data. Row tables can use the same discriminator model.
class PaymentTable(RowTable):
payment_type = discriminator_field("type")
amount = field("amount", parser=integer())
@PaymentTable.variant("card")
class CardPayment(PaymentTable):
last_four = field("last_four", required=True)
@PaymentTable.variant("bank")
class BankPayment(PaymentTable):
account = field("account", required=True)
>> PaymentTable.parse(
... [
... ["type", "amount", "last_four", "account"],
... ["card", "25", "4242", ""],
... ["bank", "50", "", "QA-001"],
... ]
... )
[CardPayment(payment_type='card', amount=25, last_four='4242'), BankPayment(payment_type='bank', amount=50, account='QA-001')]
This shape reads well when each row is naturally one event or object. The
card row needs last_four; the bank row needs account. The empty cells
for the other payment type are ignored because they do not contain data.
Choose shape before variant style
First decide whether rows or columns make the authored table readable.
Then decide whether concise TableFields components or explicit variant
classes make the schema easier to maintain.