Framework Patterns: JavaScript edition

Software developers use software frameworks all the time, so it's good to think about them. You might even create one yourself, but even if you don't, understanding the design principles underlying them helps you evaluate and use frameworks better.

A few years ago I wrote a post about patterns I've seen in frameworks. In it, while I did discuss other languages, I mostly used examples from the Python world. This is a revised version that focuses on frameworks written in JavaScript or TypeScript.

Let's give some examples of frameworks: well-known frameworks in the JS world include React, Express, NextJS and Jest. Frameworks are not all about solving the same problem and do not have to cover all aspects of your application - Jest for instance is focused on letting you write tests, but doesn't care about how you compose web pages.

Framework versus library

So what distinguishes a framework from a normal software library? You install both from npm, right? They all have a package.json.

You can see a software framework as a library that calls your application code instead of the other way around. This is known as the "Hollywood Principle": "Don't call us, we'll call you".

So whereas you can make a HTTP GET request using axios by calling the get function it exposes, you give a framework like NextJS functions and components to call, and it calls them. React lets you define functions that it then lets you combine in a tree, and React itself takes care of re-rendering parts of the tree and updating the browser when state changes.

Many libraries have aspects of frameworks so there is a gray area.

It's often claimed about React that it's "just a library", not a framework, but given that it presents a declarative way to structure UI for your applications including a special approach to state management, with support for both web UIs as well as native UIs, I certainly see it as a (micro) framework.

Frameworks make applications more declarative

The framework defines the grammar: framework-provided functions, objects, classes, types. Then you use that grammar by bringing some of the words: application-defined functions, objects, classes, types and config.

The grammar provides an organizing principle for your application, or at least parts of your application. A framework helps structure the way you write code, making it more declarative. Declarative code is code that says what it wants, not how to do it. Declarative code has less noise and tends to be easier to understand and adjust. Especially as a codebase grows over time, declarative patterns become more important to keep it manageable. Frameworks help you do that.

NextJs for instance makes your application more declarative in how particular pages match routes: you declare these by placing files with the names of these pages in a directory structure. This means that a developer new to a project can quickly see what pages exist and what code is used to render them, and easily add new pages as well.

Frameworks restrict

As developers our intuition may be that we want to use the tool with the least restrictions, so we get ultimate power and flexibility. Frameworks however do the opposite of that: they restrict what you can do. They force you to use it in a certain way, and if you step out of that, expect pain or the framework breaking. In return for following these restrictions, the framework gives you access to its powers.

This is quite similar to why we use programming languages. If ultimate power and flexibility was all that we wanted in software development, we'd all be using assembly language - it lets you exactly control which instructions are executed, and you can use memory in whatever way you want. It turns out that is very difficult to manage and understand, so instead we use higher level languages to help us do that.

An example of a restriction in React is how state is managed. Here's some BROKEN code that breaks that restriction:

import React from "react";

const myState = { value: 0 };

const Foo = ({}) => {
  const handleClick = () => {
    myState.value = 5; // Don't do that!
  };
  return <button onClick={handleClick}>{myState.value}</button>;
};

Here we manage state as a global object. When we click on a button, we modify that global state. But that doesn't do what we want: React does not notice this change, and the UI doesn't update with the new value after we click the button.

Let's fix that:

import React, { useState } from "react";

const Foo = ({}) => {
  const [value, setValue] = useState(0);
  const handleClick = () => {
    setValue(5);
  };
  return <p>{value}</p>;
};

Here we follow the restrictions of React: we manage state using its built-in useState hook. Because React restricts you in this way, React can now automatically re-render the component whenever there is a state change. That's the power the framework gives you, but you do have to buy into its restrictions.

Mega frameworks, micro frameworks

Mega frameworks are frameworks that aim to solve a large range of problems during application development. Famous examples in the web space are Rails and Django: problems solved span from UI in template rendering to interacting with the database through an ORM. When you deal with an application written with that framework you can expect the organizing principles of the framework to reach far into its code base. A newcomer to such a framework benefits by having to just look at one integrated source for solutions.

In the JS world mega frameworks are less common. Vue goes further than React in what it covers as a frontend framework: it has an official router and state management solution whereas React does not. But Vue nonetheless restricts itself to the frontend. NextJS also offers integration and supports server-side use cases, but is still focused on the UI part of the story.

Micro frameworks aim to solve one problem well. Examples of these are plenty in the JavaScript world: Express for programming an HTTP server, and React for managing a UI. The benefit of such frameworks is that an application development team is not locked into the framework so much and can adopt a collection of high-quality frameworks from a whole ecosystem. That's also its drawback: it takes effort to collect and maintain these.

A mega framework can be constructed from scratch, like Django or Rails historically were. You can also assembly a mega framework out of a selection of micro frameworks.

Whatever the size and scope of the framework, you can find patterns in them.

Configuration

So in a framework, we give our code to it, so it can call us. In order for the framework to call our code, we need to tell the framework about it. Let's call this configuring the framework. Configuration can take the form of JS/TS code, or could be done through a separate DSL.

There are many ways to configure a framework. Each approach has its own trade-offs. I will describe some of these framework configuration patterns here, with brief examples and mention of some of the trade-offs. Many frameworks use more than a single pattern. I don't claim this list is comprehensive -- there are more patterns.

Callback patterns

In the next section I discuss a number of basic patterns that help you inform the framework what application code to call.

Pattern: Callback function

The framework lets you pass in a callback function to configure its behavior.

Fictional example

This is a createForm function the framework provides. You can use it to configure what the framework should do when you save the form by providing a callback function:

import { createForm, FormData } from "framework";

function mySave(data: FormData) {
  // application code to save data somewhere goes here
}

const myForm = createForm(mySave);

Real-world example

Array.map is a (nano)framework that takes a (pure) function:

You can go very far with this approach. Functional languages do. If you glance at React in a certain way, it's configured with a whole bunch of callback functions called React components, along with more callback functions called event handlers.

Trade-offs

I am a big fan of this approach as the trade-offs are favorable in many circumstances. In object-oriented languages this pattern is sometimes ignored because people feel they need something more complicated: pass in some fancy object or do inheritance. I think callback functions should in fact be your first consideration.

Functions are simple to understand and implement. The contract is about as simple as it can be for code: you get some arguments and need to give a return value. This limits the knowledge you need to use the framework.

Configuration of a callback function can be very dynamic in run-time -- you can dynamically assemble or create functions and pass them into the framework, based on some configuration stored in a database, for instance.

Configuration with callback functions doesn't really stand out, which can be a disadvantage -- it's easier to see when someone subclasses a base class or implements an interface, and language-integrated methods of configuration can stand out even more.

Sometimes you want to configure multiple related functions at once, in which case an object that implements an interface can make more sense -- I describe that pattern below.

Pattern: Subclassing (inheritance)

The framework provides a base-class which you as the application developer can subclass. You implement one or more methods that the framework will call.

Fictional example

 import { FormBase } from "framework";

 class MyForm extends FormBase {
   load(): FormData {
     // application code here
   }
   save(data: FormData) {
     // application code here
   }
 }

const myForm = new MyForm();

Real-world example

This pattern is less common in JavaScript, which I think is a good thing. But there are examples, such as class-based React (which React has been moving away from for years now):

class Welcome extends React.Component {
  render() {
    // application code here
  }
  componentDidMount() {
    // application code here
  }
}

Subclassing questions

When you subclass a class, this is what you might need to know:

  • What base class methods can you override?

  • Which methods should you not override?

  • When you override a method, can you call other methods on this?

  • Is the method intended to be supplemented (don't forget super then!) or overridden, or both?

  • Does the base class inherit from another class also provides methods for you to override?

  • When you implement a method, can it interact with other methods on these other classes?

Trade-offs

Many object-oriented languages support inheritance as a language feature. You can make the subclasser implement multiple related methods. It seems obvious to use inheritance as a way to let applications use and configure the framework.

It's less common in JavaScript-based frameworks, perhaps because JavaScript developers have learned the lessons from other languages, or perhaps simply because classes were standardized relatively recently.

React used classes but is moving away from it. It always came with the strong recommendation only to subclass from React.Component directly, and never to create any deeper inheritance. An ORM like Sequelize also can work with classes, but my impression is that there too the inheritance hierarchy is supposed to be only a single level deep. Flat inheritance hierarchies indeed have less problems than deeper ones, as the questions above are easier to answer.

TypeScript offers the framework implementer a way to give more guidance (private/protected/public). The framework designer can put hard limits on which methods you are allowed to override. This takes away some of these concerns too, as with sufficient effort on the part of the framework designer, the language tooling can enforce the contract. Even so, such an API can be complex for you to understand and difficult for the framework designer to maintain.

I think the disadvantages of subclassing outweigh the advantages for a framework's external API. I still sometimes use base classes internally in a library or framework -- base classes are a lightweight way to do reuse there. In this context many of the disadvantages go away: you are in control of the base class contract yourself and you presumably understand it. But those are internal, and not base classes that a framework user has to know anything about at all.

Pattern: interfaces

The framework provides an interface that you as the application developer can implement. You implement one or more methods that the framework calls.

Fictional example

import { createForm, FormBackend, FormData } from "framework";

// typescript checks that you're not lying
const myFormBackend: FormBackend = {
  load(): FormData {
    // application code here
  }
  save(data: FormData) {
  // application code here
  }
}

const myForm = createForm(myFormBackend);

And inside framework:

export interface FormBackend {
  load(): FormData;
  save(data: FormData);
}

I gave a TypeScript example here, as this example is an especially good use case for that language. It works just fine in JS as well, if you just remove the FormBackend type, but with TS you get a compile-time error if you break the contract, and in JS you get a runtime one.

Alternative: interfaces with classes

In the above example we implemented the interface as an object literal, and this works well. There's an alternative implementation that uses classes (without inheritance):

import { createForm, FormBackend, FormData } from "framework";

// typescript checks that you're not lying
class MyFormBackend implements FormBackend {
  load(): FormData {
    // application code here
  }
  save(data: FormData) {
    // application code here
  }
}

const myForm = createForm(new MyFormBackend());

If you remove the type declarations, including the implements FormData bit, it works in plain JS as well, but again you won't get the benefit of compile-time checks. An advantage of the class-based approach is if you need multiple implementations of the interface each configured differently; in this case you can add a constructor to your class and store information on it, and create multiple instances of it. Then again, if you create objects on the fly you can do the same.

Real-world example

I had to look for a little while to find an example of this pattern; and then I realized the very editor I was typing in has an extension system that works this way. This is an example from the VSCode extension API:

const provider: vscode.DocumentSemanticTokensProvider = {
  provideDocumentSemanticTokens(
    document: vscode.TextDocument
  ): vscode.ProviderResult<vscode.SemanticTokens> {
    // analyze the document and return semantic tokens
  },
};

vscode.languages.registerDocumentSemanticTokensProvider(
  selector,
  provider,
  legend
);

This example in fact registers an interface with only a single method, provideDocumentSemanticTokens so it's functionally the same as the callback pattern. But it supports a range of registration APIs, some of which take more complex interfaces.

Trade-offs

The trade-offs are quite similar to those of callback functions. This is a useful pattern to use if you want to define related functionality in a single bundle.

I go for interfaces if my framework offers a more extensive contract that an application needs to implement, especially if the application needs to maintain its own internal state.

The use of interfaces can lead to clean composition-oriented designs, where you adapt one object into another.

You can use run-time dynamism with functions where you dynamically assemble an object that implements an interface.

My recommendation is to use this pattern over class inheritance in framework design, as the boundary with the application is a lot more clean.

Registration patterns

Consider a framework like Express or NextJS: given a URL it needs to find a function or React component to handle that URL. We can say that the framework dispatches to application code based on the URL.

The framework is in charge of decoding the URL and dispatching, but how does it know where to dispatch? Internally it needs some form of registry; a collection like an Array or a Map.

The code that the application registers could be a callback function, an object that implements a certain interface, or even a class.

Frameworks use different ways to let applications do this registration: we can call this configuration.

Pattern: imperative registration API

You register your code with the framework directly by invoking a function or method to make the registration.

Fictional Example

import { register, dispatch } from "framework";

register("chicken", () => "Cluck!");
register("cow", () => "Moo!");

Here we have a framework that lets us register animals and a function that should be called to make that animal's sound.

Let's look inside this framework:

type Handler = () => string;

const registry = new Map<string, Handler>();
export function register(name: string, handler: Handler) {
  registry.set(name, handler);
}
export function dispatch(name: string): string {
  const handler = registry.get(name);
  if (handler == null) {
    return "Unknown!";
  }
  return handler();
}

Our registry here is a Map where the key is the name of the animal and the value is the Handler function. That's an implementation detail however: we instead export a register function that can be used by the application developer. The dispatch function is an example of how the framework uses the registry.

In this example the dispatch is so simple we could have just as well stored the "Cluck" and "Moo" strings directly in the registry, but you can imagine an example where the functions receive parameters from the framework and do real work .

Real-world example

The Express framework for implementing web backends uses the imperative registration pattern:

router.get("/caravans", async (req, res) => {
  // application code here
});

We make the registration by calling router.get. This adds a handler object to an Array (the registry) in router. When resolving a request, Express goes through this array one by one to match the path in the URL, until it finds a matching handler.

The handler is application code that handles the request and produces a HTTP response.

The VSCode example above for interfaces also uses an imperative registration API - configuration gets stored in a Map.

Trade-offs

I use this pattern a lot, as it's easy to implement and good enough for many use cases. It has a minor drawback: you can't easily see that configuration is taking place when you read code. You can expose a more sophisticated configuration API on top of this layer: a DSL or language integrated registration, which I discuss later. But this is foundational.

The registration order can matter. What happens if you make the same registration twice? Perhaps the registry rejects the second registration. Perhaps it allows it, silently overriding the previous one. There is no general system to handle this, unlike patterns which I describe later.

Registration can be done anywhere in the application which makes it possible to configure the framework dynamically. But this can also lead to complexity and the framework can offer fewer guarantees if its configuration can be updated at any moment.

The registrations can happen anywhere. This means you can do them at the top level of a module, which can be very convenient, but it does means you rely on a side-effect of importing this module. Doing a lot of work during import time in general can lead to hard to predict behavior and makes it difficult to do overrides in a structured manner. Bundling tools like webpack also cannot perform their tree shaking optimization, reducing bundle size by dead-code elimination, in the presence of side-effects (see the sideEffects: false setting in package.json).

For these reasons it's often better to restrict an application's registration code to a specific function that needs to invoked to perform them, and not do them anywhere else.

Pattern: convention over configuration

The framework configures itself automatically based on your use of conventions in application code. Common conventions include:

  • File name conventions (name Jest test files .test.js)

  • Default or specially named module exports.

This could get more sophisticated as well, such as introspecting objects (using the Reflect API) or even function signatures.

Fictional example

export function handleChicken() {
  return "Cluck!";
}
export function handleCow() {
  return "Moo!";
}

So, anything prefixed with handle that is exported gets registered.

We need to bootstrap the framework somewhere by loading the module with our handle functions in it and introspecting it:

import * as myModule from "myModule";
autoRegister(myModule);

Let's look inside this framework. It's layered over the previous imperative registration example:

import { register } from "imperative-framework";

export function autoRegister(module) {
  Object.keys(module).forEach((key) => {
    if (key.startsWith("handle")) {
      const name = key.slice(6).toLowerCase();
      register(name, module[key]);
    }
  });
}

Real-world example: NextJS

NextJS uses convention over configuration:

  • Routes are based on filenames of modules and subdirectories in the pages directory. So, a file pages/foo/bar.js handles the URL path /foo/bar. Dynamic routes are also supported using the [param] convention: pages/post/[postId].js, which matches any URL path such as /post/one and /post/whatever.

  • Convention: the default export in a page module is the React component used to render that page. NextJS also looks at specially named functions such as getServerSideProps and getStaticProps to obtain server data to pass into the page.

Trade-offs

Convention over configuration can be great. It allows the user make code work without any ceremony. It can enforce useful norms that makes code easier to read -- it makes sense to postfix your test files with .test.js anyway, as that allows the human reader to recognize them.

I like convention over configuration in moderation, for some use cases. For cases where you have more complicated registrations to make with a large combination of parameters, it makes more sense to allow registration with an explicit API. An alternative is to use a high-level DSL.

The more conventions a framework has, the more disadvantages show up. You have to learn the rules, their interactions, and remember them. You may sometimes accidentally invoke them even though you don't want to, just by using the wrong name. You may want to structure your application's code in a way doesn't really work with the conventions.

And what if you wanted your registrations to be dynamic, based on database state, for instance? Convention over configuration is a hindrance here, not a help. The developer may need to fall back to a different, imperative registration API, and this may be ill-defined and difficult to use.

It's harder for the framework to implement some patterns -- what if registrations need to be parameterized, for instance? The framework may need more special naming conventions to let you influence that. Or alternatively it leads the framework designer to use modules, objects or classes over functions, but those have the drawback that they are more unwieldy.

Static type checks are of little use with convention over configuration -- I don't know of a type system that can tell you to implement a particular function signature if you postfix it with View, for instance.

Pattern: DSL-based declaration

You use a DSL (domain specific language) to configure the framework. This DSL offers some way to hook in custom code. The DSL can be an entirely custom language, but you can also leverage JSON, YAML or (shudder) XML.

You can also combine multiple languages: I've helped implement a workflow engine that's configured with JSON, and expressions in it are a subset of Python expressions with a custom parser and interpreter.

Fictional example

{
  "entries": [
    "chicken": "Cluck!",
    "cow": "Moo!"
  ]
}

Here we express the declarations outside of JavaScript. In this case we've used JSON.

Real world example: package.json

A package.json file is a DSL that describes a JS package:

{
  "name": "framework-patterns-example",
  "version": "1.0.0",
  "scripts": {
    "dev": "next",
    "build": "next build",
    "start": "next start",
    "type-check": "tsc",
    "lint": "eslint . --ext .js,.jsx,.ts,.tsx",
    "test": "cross-env NODE_ENV=test jest --runInBand",
    "test-verbose": "cross-env NODE_ENV=test jest --runInBand --verbose",
    "test-watch": "cross-env NODE_ENV=test jest --runInBand --verbose --watch"
  }
}

We can see plain data entries, but also an example of an embedded language in the scripts section, in this case the shell commands to use to execute the scripts.

Trade-offs

Custom DSLs are a very powerful tool if you actually need them. But they are also a lot more heavyweight than the other methods discussed, and that's a drawback.

A custom DSL is thorough: a framework designer can build it with very clean boundaries, with a clear grammar and hard checks to see whether code conforms to this grammar. If you build your DSL on JSON or XML, you can implement such checks pretty easily using one of the various schema implementations.

A custom DSL can provide a declarative view into your application and how everything is wired up. A drawback of DSL-based configuration is that it is quite distant from the code that it configures. A DSL can cause mental overhead -- the application developer not only needs to read the application's code but also its configuration files in order to understand the behavior of an application. If the DSL is high-level, this can be very helpful, but for more low-level declarations it can be much nicer to co-locate configuration with code.

A custom DSL can be restrictive. This sounds like a drawback but is in fact an important advantage: the restrictions can be built on by the framework to guarantee important properties.

A DSL can offer certain security guarantees -- you can ensure that DSL code can only reach into a limited part of your application.

A custom DSL gives the potential for non-developers to configure application behavior. At some point in a DSL there is a need to interface with user code, but this may be abstracted away quite far. It lets non-developers reuse code implemented by developers.

A DSL can be extended with a GUI to make it even easier for non-developers to configure it.

Since code written in a DSL can be stored in a database, you can store complex configuration in a database.

A DSL can implement a declaration engine with sophisticated behavior -- for instance the general detection of configuration conflicts (you try to configure the same thing in conflicting ways in multiple places), and structured, safe overrides that are independent of code and import order. A DSL doesn't have to use such sophistication, but a framework designer that designs a DSL is naturally lead in such a direction.

A DSL also provides little flexibility during run-time. While you could generate configuration code dynamically, that's a level of meta that's quite expensive (lots of generate/parse cycles) and it can lead to headaches for the developers trying to understand what's going on.

DSL-based configuration is also quite heavy to implement compared to many other more lightweight configuration options described.

Pattern: imperative declaration

You use a configuration engine and you drive it from programming language code in an imperative way, like imperative registration. In fact, an imperative declaration system can be layered over a imperative registration system.

The difference from imperative registration is that the framework implements a deferred configuration engine, instead of making registrations immediately: configuration is transactional. Configuration commands are first collected in a separate configuration phase, and only after collection is complete are they preprocessed, then executed, resulting in actual registrations.

This pattern supports configuration introspection tooling, and pluggable, extensible applications.

Fictional example

register("chicken", () => "Cluck!");
register("cow", () => "Moo!");
commit();

This looks the same to the user as imperative registration. The difference here is that register is much more sophisticated. It actually can detect conflicts between registrations for the same thing, and allows a way to do structured overrides. Only when commit is called does the registration in fact get applied.

So, if you do this:

register("chicken", () => "Cluck!");
register("chicken", () => "Moo!");
commit();

then the configuration engine tells you upon commit that you can't register two things for "chicken". It doesn't matter if these register calls happen far away from each other.

The configuration engine also allows you to override its default behavior. Let's say we have a special application profile "rooster" where we want the chicken to do something else:

register("chicken", () => "Cock-a-doodle-do!", "rooster")
commit();

Real-world example?

This is an underutilized pattern. Do you know of an example in the JavaScript world?

Even in the Python world, where the Pyramid web framework used this (and I use a language-integrated version of it in Morepath) it isn't used very often.

Trade-offs

This looks very similar to language-integrated registration but the behavior is declarative.

This brings some of the benefits of a configuration DSL to code. Like a DSL, the configuration system can detect conflicts ("the route name 'hello' is registered twice!"), and it allows sophisticated override patterns that are not dependent on the vagaries of registration order or import order.

Another benefit is that configuration can be generated programmatically, so this allows for a certain amount of run-time dynamism without some the costs that a DSL would have. It is still good to avoid such dynamism as much as possible though, as it can make for very difficult to comprehend code.

You can try to co-locate registrations with code, or do all registration in a separate location. But if you do co-locate registration, you risk running into JavaScript's growing aversion to module side-effects unless you take special measures; see the discussion about bundle size above.

Declarative registration a lot more heavy-weight than just passing in a callback or object with an interface -- for many frameworks that is more than enough ceremony, and nothing beats how easy that is to implement and test.

Registration pattern layering

Framework designers often directly implement a DSL or a convention over configuration system without too much consideration of how things get registered.

That is unfortunate, as I think defining a clean imperative declaration API layer underneath leads to a cleaner, easier to maintain and understand framework implementation.

The bottom of the configuration layer is an imperative declaration API. You can then layer convention over configuration, a DSL or an imperative declaration API over it.

Type patterns

The following patterns are specific to TypeScript. The idea is to let the type checker support the developers that use a framework - it gives the developer clear error messages and code autocomplete in their editor.

Pattern: Type Checking

Establish clear boundaries in code by specifying function type or interface.

We can be brief about this as we saw it in the interface pattern example above.

Pattern: Generic Types

Normally we give the framework our application code. But with TypeScript, we can also give the framework an application level type, so that it can use it to typecheck your code elsewhere.

export function registerThing<T>(thing: T, validateThing: (thing: T) => boolean) {
  // something frameworky
}

This code is very generic: it works over any thing type `T. Let's write a concrete type as an example:

type SomeThing = {
   name: string
   value: number
}

And something that implements the type SomeThing:

const myThing: SomeThing = {
   name: "Some Thing",
   value: 3
}

Then we pass the type SomeThing explicitly as T:

registerThing<SomeThing>(myThing, thing => thing.value > 2)

This way the framework knows about this type and uses it to typecheck the validateThing function argument as well.

Pattern: Generic Type Inference

Using generic types explicitly is rather heavy. Instead, we can let the framework API infer the type of the generic type argument.

This already works in the example above: since we have a parameter thing of type T we can also omit the generic type as it can be inferred:

registerThing(myThing, thing => thing.value > 2)

Because of type inference, TypeScript still knows the thing argument of the validateThing function is of type SomeThing.

A good framework API in TypeScript wants to be easy to use while avoiding any and offer type checking where possible. Generic type inference can be used to enable this.

Pattern: Type Generation

Sometimes the type information we want to use with a framework are not available as typescript definitions: they are available in some specification, or perhaps a database schema. To support development a framework can generate the types for the developers from this other source.

This pattern can even make a library behave a bit more like a framework in that you fill in the gaps to make it work with your application - but the gaps you fill in are not in the form of callbacks but types, derived from another source.

Real world examples include:

  • You have an OpenAPI specification of your REST web service. But during the implementation using a framework, for instance using Express, you want to make use of types derived from this, instead of redefining them, so that you are more sure you are implementing the right specification. You can use a tool like openapi-typescript to help you do that.

  • You are using Slonik, a library to write SQL embedded in TypeScript code. But Slonik cannot derive the types of SQL queries. So you use @slonik/typegen to automatically generate these types during runtime, getting you the benefits of type checking.

  • You are using Contentful as a CMS. The types are maintained by Contentful. But you want typechecking for CMS contents you retrieve from the Contentful API, so you use contentful-typescript-codegen. You do these by passing them into the contentful API library as generic types, turning this library a bit more into an application-specific framework.

Conclusion

I hope this overview helped you understand the decisions made by frameworks a bit better.

And if you design a framework -- which you should do, as larger applications need frameworks to stay coherent -- you now hopefully have some more concepts to work with to help you make better design decisions.

Comments

Comments powered by Disqus