< See all articles

Writing Software Specs in TypeScript

Published on Fri Oct 04

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 ?

The Use Case FlowThe Use Case Flow

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.

Disclaimer 1 : Some readers will cry blood when seeing such a strict typing. You might be an untyped code afficionado, but please accept that other people prefer very strict typing, even if it's more verbose. Life is more beautiful with nuance.
Disclaimer 2 : It's already possible to define such things with tools like TypeSpec and we've experimented with them. But we considered them to be to technical. See for example, their tagline is "Describe APIs".

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: IS_SERVER
            ? {
                  main: BuyAssetServerMain,
                  policy: AuthenticatedUseCasePolicy,
              }
            : true,
    },
    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...
Can you already identify the huge benefits of all of this ? Add an input field to a use case and the server is updated automatically, the web form displays it automatically, the form in your React Native app displays it automatically, the CLI provides the argument automatically... all without relying on code generation tools. One single source of truth !

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