Writing Software Specs in TypeScript
Relying on TypeScript to write fully typed specs in order to build better software, making the code cross-platform, auto-testable, auto-documentable and much more.
In a previous article, we've discussed how we could build better software. First, the usage of "better" does not mean that what we're all currently doing is "bad". But we think that it can be improved. If "better" bothers you, you can replace it by "differently".
As explained in the article, we've been building a "meta-framework" for more than two years now. Even though it's not necessarily production-ready for all kinds of businesses/ideas yet, we've been using it in production for multiple solutions, including web apps, mobile apps and cli utilities.
The main properties of the framework is that it focuses on business cases first and platform "agnosticity". Such properties allow us to write cross-platform applications in TypeScript very quickly, and deploy them anywhere on anything that can run JavaScript.
One day, we'll open source this framework so everyone can use it and build applications with it. But as said previously, it's clearly not ready yet. That being said, sharing is a core part of our industry and we love it, so in the following articles, we'll highlight some key aspects of it.
Let's start by the first aspect : the notion of Use Case. It's a generic interface allowing us to write the specs in TypeScript. See it as "SaC" (Specs as Code), like "IaC" (Infrastructure as Code) popularized by HashiCorp Terraform. If UML comes to your mind directly when you read "Specs as Code", please leave.
Why writing Use Cases in TypeScript ?
Before deep diving into the concept, let's first explain why.
We deeply believe that the first step of writing any software is to write its specification. It does not have to be a 300 pages document, but highlighting the idea first can help defining it and identify problems. Most of us do it in a "throwable" Google Doc but it has a problem : it's not "instrumentable" deterministically (looking at you ChatGPT fanboys).
Writing specs in TypeScript allows us to already start writing code, to detect common patterns, to centralize common parts... without writing any actual "infrastructure" code. Plus, as highlighted in the previous article, it gives us a lot of tools to instrument the specs into anything needed (working code, automated tests, automated documentation, etc.).
What is a Use Case ?
A use case has an input (a collection of fields), a client main (function executed client side), a server main (function executed server side) and an output. That's it : no less, no more. All the rest is related to the platform (web, mobile, etc.) and we don't care about it for now : separation of concerns. You can build anything with this.
Naturally, each item of the flow is optional :
- No input : you simply want to trigger an action with no args
- No client main : you simply want to perform actions server side
- No server main : you simply want to perform actions client side
- No output : you simply want to perform an action that returns nothing
Our first Use Case as code
To illustrate, let's define a real world business need : a trading platform1. And the first thing we can do on such a platform is to Buy/Sell assets. So let's start defining BuyAssetUCD.ts
.
Input
export interface BuyAssetInput extends UseCaseInput {
code: UseCaseInputFieldValue<ISIN>;
limit: UseCaseInputFieldValue<Amount>;
qty: UseCaseInputFieldValue<Qty>;
}
Nothing fancy here, it's pretty self explanatory. To keep things simple for now, we won't deep dive into each type but keep in mind that they exist for a specific reason. For example, UseCaseInputFieldValue
corresponds to T | T[] | null | undefined
.
Notice as well how we don't use primitive types like string
or number
, but instead, fully specified real world data types. This will come handy to perform validation and write tests.
Output
export interface BuyAssetOPI0 extends UseCaseOPIBase {
executedDirectly: boolean;
}
Within the framework, the output has a predefined generic schema composed of mutliple OutputParts
, composed themselves of a collection of OPIs
(OutputPartItems
), plus other fields.
Therefore, the use case only has to define what the OPIs
look like.
ClientMain
@injectable()
class BuyAssetClientMain implements UseCaseMain<BuyAssetInput, BuyAssetOPI0> {
constructor(
@inject('UseCaseTransporter')
private useCaseTransporter: UseCaseTransporter,
) {}
public async exec({
useCase,
}: UseCaseMainInput<BuyAssetInput, BuyAssetOPI0>): Promise<void | UseCaseOutput<BuyAssetOPI0>> {
return this.useCaseTransporter.send(useCase);
}
}
Client side, we simply want to send the input data to the server. Hence the usage of the built-in UseCaseTransporter
.
Two main things to notice here :
- No mention of HTTP, REST, JSON... It can be anything (GRPC, XML, RPC, etc.)
- Usage of dependency injection in order to always keep the use case "infrastructure" agnostic
ServerMain
@injectable()
class BuyAssetServerMain implements UseCaseMain<BuyAssetInput, BuyAssetOPI0> {
constructor(
@inject('CryptoManager') private cryptoManager: CryptoManager,
@inject('Logger') private logger: Logger,
) {}
public async exec({
useCase,
}: UseCaseMainInput<BuyAssetInput, BuyAssetOPI0>): Promise<UseCaseOutput<BuyAssetOPI0>> {
const code = useCase.requireFromInput<ISIN>('code');
const limit = useCase.requireFromInput<Amount>('limit');
const qty = useCase.requireFromInput<UIntQuantity>('qty');
this.logger.debug('Buying an asset', { code, limit, qty });
// TODO : Find the asset by its code. Fail if not found.
// TODO : Check if the customer can buy the code.
// TODO : Check if the customer has enough funds.
// TODO : Send the order on an async queue => executedDirectly: false
// TODO : Or process directly if applicable => executedDirectly: true
// And so on...
const id = this.cryptoManager.randomUUID();
return new UseCaseOutputBuilder<BuyAssetOPI0>()
.add({ executedDirectly: false, id })
.get();
}
}
This is where everything business related happens. As on the client side, we rely on dependency injection as well, to keep the same criteria of "infrastructure" angosticity.
Right now it does not do much (see the TODOs
). But you can already see how things are articulated to get the input and produce the output.
For instance, to implement the first TODO
, we would inject the ad-hoc service in the constructor (e.g. AssetService
) and use it.
Definition
export const BuyAssetUCD: UseCaseDef<BuyAssetInput, BuyAssetOPI0> = {
io: {
input: {
fields: {
code: {
type: new TISIN(),
},
limit: {
type: new TAmount('USD'),
},
qty: {
type: new TUIntQuantity(),
},
},
},
output: {
parts: {
_0: {
fields: {
executedDirectly: {
type: new TExecStatus(),
},
},
},
},
},
},
lifecycle: {
client: {
main: BuyAssetClientMain,
policy: AuthenticatedUseCasePolicy,
},
server: {
main: BuyAssetServerMain,
policy: AuthenticatedUseCasePolicy,
},
},
metadata: {
action: 'Create',
description: {
en: 'Place an order of type "Buy" corresponding to the specified code',
fr: 'Placer un ordre de type "Achat" correspondant au code spécifié',
},
icon: 'add',
isBeta: true,
isNew: true,
label: {
en: 'Buy an asset',
fr: 'Acheter un instrument',
},
name: 'BuyAsset',
},
};
Finally, we put things together within the UseCaseDef
in order to have a self-contained unit that can be used universally.
Again, one of the main rules of a use case is that it must never depend on anything of the outside world, which means, no external dependencies.
ClientMain
and ServerMain
can of course inject interfaces or use utility functions/clases but these must be platform agnostic as well.
Have you noticed how we also redefine the io
with more precise information, how we define the permissions with policy
and also how we define some general metadata
?
Conclusion
That's already a lot to ingest, so this is it for this article presenting the use case definition.
As you can see we've been able to present a business case without mentioning any framework (React, Angular, ...), any runtime (Web, Node, Bun, ...), any persistence (MySQL, PostgreSQL, ...). Even if it's TypeScript, show this to a non-technical person and they will understand what's going on.
That being said, as you might have guessed, this is only the surface. In the next articles, we'll see everything the framework does for us automatically :
- Server (whatever you prefer : Express, Fastify, Hono...)
- Web Client (whatever you prefer : React, Angular, Vanilla...)
- Mobile (whatever you prefer : React Native, React Native...)
- CLI (whatever you prefer : commander, node parseArgs, stricli...)
- Transport with serialization/deserialization
- Validation
- Auto-generated forms with automated client side validation (using any layout you prefer)
- Automated tests offering 100% coverage and human readable reports
- Documentation
- Flow diagrams explaining how use cases are linked together
- Optional input fields, specific validation rules, custom data types...
- Translation
- and much much more...
Stay tuned.
1 Dear colleagues, when you're showing how a framework works, please stop with "Hello World" and "FooBar" examples. The world is rich and full of examples, use them to make things more intelligible.
Chafik H'nini