DEV Community

Sleeyax
Sleeyax

Posted on

Write Dynamic GitLab pipelines in TypeScript

As CI/CD pipelines evolve, they tend to grow in size, complexity, and inevitably... frustration. If you’ve ever maintained a large .gitlab-ci.yml file, you’ve probably felt the pain: copy-paste everywhere, no type safety, and mistakes that only surface when a runner fails at runtime.

This article introduces an alternative approach: defining GitLab pipelines in TypeScript, while still producing a perfectly valid .gitlab-ci.yml file that GitLab can run as-is.

Why TypeScript for GitLab pipelines?

GitLab CI configuration is written in YAML. YAML is fine for small pipelines, but once pipelines become complex, YAML starts to feel limiting:

  • No type checking
  • No refactoring support
  • No real abstraction or reuse
  • Errors show up late, during pipeline execution

Meanwhile, modern CI/CD platforms like PandaCI already allow pipelines to be defined in a real programming language out of the box.

Unfortunately, GitLab doesn’t support this natively.

I explored existing solutions, but none quite hit the mark:

What I wanted was something:

  • Simple and lightweight
  • Easy to update when GitLab’s schema changes
  • Mapped 1:1 to the official GitLab CI schema

So I built a small library to do exactly that: gitlab-ci-ts.

The core logic is only ~30 lines of code. The rest is auto-generated TypeScript types from GitLab’s schema.

The workflow

Instead of writing YAML by hand:

  1. Define your pipeline entirely in TypeScript
  2. Compile it to a .gitlab-ci.yml file
  3. Commit both the TypeScript source and the generated YAML

GitLab still only sees YAML, but you get the benefits of TypeScript.

// gitlab-ci.ts
import { Cache, GitLabCI, transformToFile } from "@sleeyax/gitlab-ci-ts";

// Reusable cache.
const nodeCache: Cache = {
  key: { files: ["package-lock.json"] },
  paths: [".npm/"],
};

// General-purpose pipeline.
export const pipeline: GitLabCI = {
  default: {
    image: "node:20",
    cache: [nodeCache],
  },

  variables: {
    GIT_DEPTH: 0,
  },

  stages: ["test", "build", "deploy"],

  jobs: {
    // Hidden job to share install steps.
    ".install": {
      stage: "test",
      before_script: ["npm ci --prefer-offline --no-audit --if-present"],
      interruptible: true,
      cache: [nodeCache],
    },

    // Individual jobs.
    lint: {
      extends: ".install",
      stage: "test",
      script: ["npm run lint --if-present"],
      rules: [{ if: "$CI" }],
    },

    test: {
      extends: ".install",
      stage: "test",
      script: ["npm test --if-present"],
    },

    build: {
      extends: ".install",
      stage: "build",
      script: ["npm run build --if-present"],
      cache: [nodeCache],
      artifacts: { paths: ["dist/"] },
    },

    docker_push: {
      image: "docker:28",
      services: ["docker:28-dind"],
      stage: "deploy",
      before_script: [
        'echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin',
      ],
      script: [
        "docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .",
        "docker push $CI_REGISTRY_IMAGE --all-tags",
      ],
      rules: [{ if: "$CI" }],
    },
  },
};

// Write ".gitlab-ci.yml" file.
await transformToFile(pipeline, ".gitlab-ci.yml");
Enter fullscreen mode Exit fullscreen mode

Ready to play around with it yourself? 👇

pnpm add @sleeyax/gitlab-ci-ts
Enter fullscreen mode Exit fullscreen mode

See the gitlab-ci-ts repository for more examples and documentation.

Pros of TypeScript-driven pipelines

  • Type safety. Configuration mistakes get caught at compile time, not during a failing pipeline run.
  • Reuse and abstraction. You can share constants, extract helpers, and centralize common job definitions without copy-pasting YAML blocks across files.
  • Developer experience. You get autocomplete, inline documentation (TSDoc), safe refactoring, and earlier detection of invalid or unused variables.

Structure it however you want

Because the pipeline is just code, you’re free to organize it in a way that actually makes sense.

apps/pipeline
├── package.json
├── src
│   ├── cache.ts
│   ├── gitlab-ci.ts
│   ├── jobs
│   │   ├── environment
│   │   │   ├── development.ts
│   │   │   ├── production.ts
│   │   │   └── staging.ts
│   │   └── stages
│   │       ├── build
│   │       ├── deploy
│   │       ├── test
│   ├── main.ts
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

Future possibilities

Once your pipeline is defined as code, new doors open:

  • Unit tests for pipeline configuration
  • Easier large-scale refactors
  • Programmatic validation of rules, variables, and stages

Trade-offs and limitations

Obviously, this approach isn’t without downsides:

  • Extra abstraction layer. If GitLab introduces a feature that isn’t represented in the generated types yet, you may need to update the library.

  • Extra build step. You must compile the TypeScript to .gitlab-ci.yml before committing.

Final thoughts

Defining GitLab pipelines in TypeScript brings modern software engineering practices to CI/CD:

  • Type safety
  • Reusability
  • Testability
  • Maintainability

GitLab still runs YAML, but you don’t have to write it anymore.

Top comments (0)