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:
- node-gitlab-ci - no longer actively maintained
- gitlab-dynamic-pipelines - a manually coded wrapper rather than a schema-accurate representation
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.
If you're wondering why I don't just use includes, extends, anchors/references and gitlab-local-ci instead, see this comment.
The workflow
Instead of writing YAML by hand:
- Define your pipeline entirely in TypeScript
- Compile it to a
.gitlab-ci.ymlfile - 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");
Ready to play around with it yourself? 👇
pnpm add @sleeyax/gitlab-ci-ts
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
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.ymlbefore 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 (3)
I love uncharted territories on GitLab, thank you for sharing ! You should add #gitlab tag, it has better traction that some of the ones you defined.
For now i do not really understand the advantages f your workflow...
gitlab-ci-local can also do it, without any abstraction layer.
extends, references and includes can also do it without any abstraction layer.
IDE extension and gitlab-ci-local can also do that without any abstraction layer
Thank you for the reply. I appreciate your input!
Let me clarify how this fits my workflow:
1. "Type safety" via
gitlab-ci-localThat’s not really instant type checking though.
gitlab-ci-localvalidates YAML after it’s written and run through a separate tool. AFAIK it doesn’t prevent you from writing invalid configs in the first place.It’s also a fairly large, non-official dependency that we’d need to enforce across the team. In our case, we already work exclusively in TypeScript in a complex NX monorepo, so this is just native TS tooling doing what it already does best.
2.
extends,includes, anchorsWe used all of those extensively before. They help, but they’re still string-based and fairly fragile: typo a job name, forget to include a file, or misunderstand merge behavior and things silently stop working.
Importing files and composing objects in TypeScript proved more simple for us.
3. IDE extensions / DX without abstraction
Interesting. I wasn't aware of a widely used IDE extension for GitLab CI that gives the same level of refactoring and safety. Which one? I have
GitLab workflowinstalled in vscode and for.gitlab-ci.ymlfiles specifically it only helps with hinting the YAML field names.Anyways, I feel like enforcing a specific IDE plugin across the team is still not the most ideal approach. With TypeScript, autocomplete, refactoring, docs, and error checking are already there. No extra tooling or setup beyond what we already use every day.
Conclusion
Just to be clear, the goal of this article isn't to say "GitLab YAML bad". This different approach just fits our project and team better. We’re already very TS-minded, and once pipelines reach a certain size and complexity, treating them as code instead of configuration has proven easier to reason about and maintain for us.
I'll try to update the article so it's a little more nuanced.
Thanks. Indeed you would gain depth in it by addressing inner reader questions like those.
i've never been around a project with development abstraction layer on GitLab for my past 7 years actively doing GitLab consulting, But I'd like to see that in action whenever I get the chance. But for now, nothing convince me of doing it for myself (on projects I get to choose).