There were two rival schools in medieval European philosophy: nominalists and realists. The former insisted that it makes no sense to consider and discuss any abstractions, we should focus on single species instead, e.g. there's no such a notion like horse, all the horses we know are unique and can't be anyhow generalized.
The latter, in contrast, tended to neglect any particular feature of some entity's instances: no matter whether it's black or white, it's a horse first of all.
It has taken quite a long time until dialectics learned how to elegantly combine contradicting ideas like these into something solid and making perfect sense, demystifying the eternal tradeoff between flexibility and confidence.
But what do these two conceptions mean in the context of software development?
This process is driven by two sides — customer and developer, including the cases when both sides live in the single mind (sane mind, of course).
For developer, being general is the key for simplicity and clarity.
In contrast, customer usually tends to particularity, leading by natural desire to express the domain, he's responsible for, as much as possible.
Let's consider an example to understand how these parties act in the process of tailoring type system for some domain.
What about Algebra? Hope, it will be clear in the end.
Imagine yourself developing a catalog for a network of wine-bars, and a customer wants it to have a two-level division: first by the origin country, and then by the sort of grape. You and your customer came to agreement that he provides you with the backend API along with correspondent type declarations.
1. Free as a bird
Initially he gave you the type and the API method
returning the list of wine pages you have to render.
type Country = 'France' | 'Italy' | 'Spain' | 'Germany' | 'USA';
type Grape = 'Cabernet' | 'Syrah' | 'Temporanillo' | 'Chardonnay' | 'Riesling' | 'Viura';
export interface AbstractWinePage {
origin: Country;
grape: Grape;
quantity: number;
rating: number;
}
declare function getWinePages(): Promise<AbstractWinePage[]>;
Does not look too restrictive, right? This type system, based solely on Product, allows to refer to every combination of countries and grapes ever imagined. In other words it covers the whole space of cartesian product of these two dimensions.
In response you come up with elegant and simple solution:
export const renderAbstractPage = (page: AbstractWinePage) => {
return `${page.grape}/${page.origin} [${page.quantity}]`;
};
Does not look too restrictive, right? This type system, based solely on Product, allows to refer to every combination of countries and grapes ever imagined. In other words it covers the whole space of Cartesian product of these two dimensions.
Don't even look on the implementation, it's all about types. According to the non-demanding type we deal with, our function is extremely versatile.
It makes no assumptions on particular wine pages the backend service provides us with.
The inevitable downside of such an uniformity is scarcity — all the wine pages looks absolutely the same, which perfectly fits the current requirements, though.
2. The offer you can not refuse
Next week customer informs you that he's running special offers, as a part of his marketing strategy. New version of the type system looks accordingly:
export interface AbstractWinePage {
origin: Country;
grape: Grape;
quantity: number;
rating: number;
specialOffer: SpecialWineOffer;
}
export interface SpecialWineOffer {
id: string;
description: string;
}
So, you have to slightly rework your rendering framework:
export const renderAbstractOffer = (offer: SpecialWineOffer) => {
return offer.description;
};
export const renderPageWithOffer = (page: AbstractPage) => {
return `${renderAbstractPage(page)} \n ${renderAbstractOffer(page.specialOffer)}`;
};
Its versatility, nevertheless, has not diminished at all — we're still able to render arbitrary page with arbitrary special offer. Sad for us, that couldn't longs forever.
The problem is that this type system still doesn't match long term customer's vision:
- Scarcity of visual capabilities, we mentioned above — all pages looks similar to each other. He wants special offers to be distinguishable and interesting to clients. His idea is to make some pages having a unique design, furthermore, he wants to integrate the special offer into the very structure of the page, instead of rendering as mere some section of page, following the common pattern.
- Yet more concern — this type system is overly permissive. That may seems cool at first glance, but as the project grows, it could become much more troublesome. Static analysis is one of inalienable goals of typechecking, after all. For starters, he wants to restrict the possible combinations of country and grape, to match the variety of particular bar, and to exclude ones which does not make sense at all, e.g. have you ever heard about Spanish Riesling or German Syrah?
3. Chained, but not broken
Thus, much more substantial changes were introduced:
interface WinePageGenerator<
CO extends Country,
GR extends Grape,
OF extends SpecialWineOffer
> {
origin: CO;
grape: GR;
quantity: number;
rating: number;
specialOffer: OF;
}
interface NapaSpecialOffer extends SpecialWineOffer {
id: 'napa';
tourDate: string;
}
interface RiojaSpecialOffer extends SpecialWineOffer {
id: 'rioja';
// some details i'm too lazy to think about
}
export type WinePages =
| WinePage<'USA', 'Cabernet', NapaSpecialOffer>
// dozens of them
| WinePage<'Spain', 'Temporanillo' | 'Viura', RiojaSpecialOffer>;
declare function getWinePages(): Promise<WinePages[]>;
Let's take a close look on the new version of type system. First of all, our old good AbstractWinePage
was replaced with its parametrized counterpart WinePageGenerator
, which is generic over the country, grape and special offer. Note, that it's not exported anymore, i.e. it's not public — it serves some miscellaneous purpose instead. It still allows to address every possible combination of country and grape (and assign every special offer we have in our disposal to this combination), but it's only can be consumed via exported type named WinePages
, which in its turn imposes some restrictions:
In contrast to WinePageGenerator
, WinePages
is an example of Sum Type.
These names -- Product and Sum aren't accidental, of course. The types we're dealing with are
Algebraic Data Types, because they comprise an Algebra!
What do these changes mean for the code of your application? First of all — renderPageWithOffer
is not enough anymore, because some pages now deserve their unique rendering, in order to highlight the features of correspondent special offer at full extent.
So, you started with dedicated method for the page for American Cabernet, raffling a tour to some remarkable wineries of Napa valley:
const renderNapaPage = (page: WinePages) => {
return `${renderAbstractPage(page)} ${page.specialOffer.tourDate} \n ${renderAbstractOffer(page.specialOffer)}`;
};
but faced with expectable problem:
Property 'tourDate' does not exist on type 'NapaSpecialOffer | RiojaSpecialOffer'.
Property 'tourDate' does not exist on type 'RiojaSpecialOffer'. (tsserver 2339)
You can not render the page for Napa, without explicitly acknowledging it in the signature of the rendering function.
But despite that you was asked by customer to work on these pages, there's no access to the types of single pages (that's why the public type is named WinePages
, not WinePage
).
You may ask your customer to fix that, but luckily there's a better approach.
What do we know about this page? — It's about American Cabernet. Surprisingly, it's enough to deal with it, treating WinePages
as a solid black box. OK, not hundred percent black, as we know that it at least contains the page we need.
Let's recall how our new WinePages
type is designed. It's essentially an enumeration of all possible combinations. The Sum
operation of the type algebra is capable to add an arbitrary type to another arbitrary type, otherwise it wouldn't be algebra. Quite powerful, but we utilize this power in very restrictive manner — just adding absolutely uniform (WinePageGenerator
) items to each other. That resembles some familiar pattern from another realm of computer science, doesn't that? Yes, the table of relational
database. Actually, Relation
itself. Good for us, relations comprise their own algerba too. This algebra provides us with Selection
operator to allow us to refer exactly to the relation tuples, we need. We know it as the corner stone of SQL — Select
query. The question is does the TypeScript provide us with something similar?
In fact, it does.The utility type Extract
allows us to focus on fragments of big types, just specifying what we know about these fragments for sure, without any assumptions about how exactly the big type has been constructed. Needless to say, we have to be specific enough to ensure the granularity we need.
export type ConcreteWinePage<CO, GR> = Extract<WinePages, { origin: CO; grape: GR }>;
export const renderNapaPage = (page: ConcreteWinePage<'USA', 'Cabernet'>) => {
return `${renderAbstractPage(page)} ${page.specialOffer.tourDate} \n ${renderAbstractOffer(page.specialOffer)}`;
};
const renderPage = (page: WinePages) => {
if (page.origin === 'USA' && page.grape === 'Cabernet') {
return renderNapaPage(page);
} else {
return renderPageWithOffer(page);
}
};
The way we do pattern-matching may seem naive (because it is indeed naive), nevertheless it shows how we can maintain the versatility everywhere it's possible, being certain and precise everywhere it's required.
God bless the Algebra.
Top comments (0)