Skip to content

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.

A table with article and poll records
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.

Declarative content variants
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:

  • id
  • content_type
  • headline

The variant components declare fields that only apply to one selected content type:

  • ArticleFields declares body
  • PollFields declares options
A mixed content table
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.

Parsing declarative variants
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"]
Selected variant values
>> 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.

Getting generated variant classes
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).

Explicit content variant classes
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.

Parsing explicit variants
article, video = ExplicitContentTable.parse(
    [
        ["IDs", "A-1", "V-1"],
        ["Type", "Article", "Video"],
        ["Headline", "Market brief", "Launch clip"],
        ["Body", "Full text", ""],
        ["URL", "", "/launch-video"],
    ]
)
Explicit variant records
>> 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.

A content type with no registered variant
ContentTable.parse(
    [
        ["IDs", "X-1"],
        ["Type", "Video"],
        ["Headline", "Clip"],
        ["Body", ""],
        ["Options", ""],
    ]
)
Unknown variant diagnostic
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.

An article missing its required body
ContentTable.parse(
    [
        ["IDs", "A-1"],
        ["Type", "Article"],
        ["Headline", "Market brief"],
    ]
)
Selected variant required-field diagnostic
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.

A poll with an article body
ContentTable.parse(
    [
        ["IDs", "P-1"],
        ["Type", "Poll"],
        ["Headline", "Reader question"],
        ["Body", "Unexpected article text"],
        ["Options", "Yes, No"],
    ]
)
Inapplicable field diagnostic
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.

A normalized discriminator
class NormalizedContentTable(RowTable):
    content_type = discriminator_field(
        "type",
        parser=lambda value, context: value.casefold(),
    )


@NormalizedContentTable.variant("article")
class NormalizedArticle(NormalizedContentTable):
    body = field("body")
Parsed discriminator value
>> 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.

Per-variant output models
@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(","))
Variant output objects
>> 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.

A row table with payment variants
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)
Row-table variant records
>> 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.