autoconf/CMake-like file generation for TypeScript

In C/C++, it is possible to write something like:

// version.h

extern const char *branch;
extern const char *hash;
extern const char *status;
extern const char *timestamp;

// version.c (autogenerated)

const char *branch = "dev";
const char *hash = "deadbeef";
const char *status = "dirty";
const char *timestamp = "1970-01-01T00:00:00.000Z";

// main.c

#include <stdio.h>
#include "version.h"

int main(void) {
    printf("Build: %s-%s-%s %s\n", branch, hash, status, timestamp);
}

This way, after a fresh git clone, the editor will not complain about the lack of variables, since version.h always exists and “promises” the variables will exist when requested at compile/runtime. Importantly, the autogenerated version.c and handwritten version.h are guaranteed not to drift apart, since such a drift will cause compilation to fail.

The “equivalent” for .h files in TypeScript is .d.ts, which also “promises” things will exist at runtime.

// version.d.ts

export declare const branch: string;
export declare const hash: string;
export declare const status: "dirty" | "clean";
export declare const timestamp: Date;

// version.ts

export const branch = "dev";
export const hash = "deadbeef";
export const status = "dirty";
export const timestamp = new Date(0);

// index.ts

import { branch, hash, status, timestamp } from "./version.js";

console.log(`Build: ${branch}-${hash}-${status} ${timestamp.toISOString()}`);

However, unlike in C/C++, TypeScript does not check that a corresponding concrete implementation exists in some .ts file, since it is possible to hook up with some other JS library at runtime, which does not go through TSC. Thus, such a check will need to be done manually, e.g., using the TS Compiler API. Which feels like an excessive amount of effort for what I’m actually trying to achieve. Or is it?

Stepping back a bit, my goal is simply to avoid requiring the autogeneration to be run after a fresh checkout and only when actually building, while still statically guaranteeing the handwritten “promises” and the autogenerated files will never go out of sync. Is there some other way to achieve this?

No, TypeScript does not natively enforce that a .d.ts file’s declarations are actually implemented in a .ts or .js file. So unlike C/C++, the compiler won’t fail if the .d.ts “promises” something that doesn’t exist at runtime. That check must be done manually.


What you’re doing is mostly correct, but here’s the key point:

If you want to guarantee sync between declaration (.d.ts) and implementation (.ts), TypeScript alone cannot enforce that at compile time.


Simple ways to handle this:

Option 1: Use a fallback stub implementation checked into the repo

  • Keep version.ts (not just .d.ts) in the repo with default dummy values.
  • Overwrite version.ts only at build time.

Example version.ts (default):

export const branch = "unknown";
export const hash = "0000000";
export const status = "dirty";
export const timestamp = new Date();

Autogeneration replaces this file only during the build, so the type definitions and implementation are always in sync, and the project compiles after a fresh clone.


Option 2: Generate both .ts and .d.ts files together

If you’re generating version.ts, generate version.d.ts from the same script. This guarantees sync. You don’t need to write the .d.ts by hand.


Option 3: Use a TypeScript plugin or custom TSC check (more complex)

You could write a small custom TSC plugin or use the TypeScript Compiler API to verify that the declarations in .d.ts have matching implementations. But yes, it’s a lot of work for something simple.


Recommended solution:

Use a dummy version.ts with defaults that’s always present and overwritten during build. This keeps:

  • Compilation working after fresh clone
  • Declarations and implementations in sync
  • No manual .d.ts needed

Just like the C version.h file — you keep it, but version.c is autogenerated. Same concept here.