Why I Stopped Writing Bash Scripts and Started Using Dart

Why I Stopped Writing Bash Scripts and Started Using Dart

Every project has a list of frequently performed actions, such as bootstrapping or running tests with coverage. Developers often choose Bash or similar tools (e.g., Makefiles) to automate tasks. In this article, I highlight where Bash is lacking and why I started preferring Dart.

  1. Limits of Bash
  2. Why You Should Use Dart Instead
    1. Dart Scripts are The Official Way
  3. Refactoring Bash Script
    1. Handling Arguments
    2. Finding Packages with Tests
    3. Shuffling and Creating Shards
    4. Running Tests and Collecting Summary

Limits of Bash

Bash was never designed for complex application logic. As scripts grow, they may hit a ceiling of maintainability and safety.

  • Lack of Type Safety: Bash is untyped. A variable can be a string, a number, or a filename, and you often won't know you have a mismatch until the script crashes in the runtime.
  • Unfriendly Syntax: The syntax is notoriously unforgiving. In a statement like if [ "$a" = "$b" ];, missing a single space or quote can cause a syntax error. These typos are rarely caught before runtime.
  • Hard to Maintain: A 50-line Bash script is readable; a 500-line Bash script is a liability. Complex scripts often become write-only code and can be changed only with AI.
  • Bad Cross Platform: Writing cross-platform Bash is harder than it looks. Standard tools like sed and grep behave differently on macOS vs Linux. Windows requires WSL or Git Bash to run them at all.

Bash is still excellent for what it does best: glue code. It is perfect for short, linear tasks, such as triggering a build command or formatting files in CI (e.g., dart format .).

Why You Should Use Dart Instead

Dart is a superior choice if the code contains conditions, loops, data parsing, or reusable logic. It removes the fragility of shell scripting and provides a great DX:

  • Safe. Dart is a strongly-typed language with a familiar syntax that is easy to read & write. It runs consistently across macOS, Windows, and Linux without worrying about OS specifics.
  • Powerful. First-class async support, native JSON handling, and full-featured I/O provide all the tools needed for complex scripts.
  • Scalable. You gain access to pub.dev ecosystem. Additionally, you can easily reuse, version, and share the scripts.

To learn more about creating CLI programs with Dart, check out Create CLI with Dart.

Dart Scripts are The Official Way

The Dart team uses Dart Scripts heavily in all their new tools. Here is an example from ffigen:

// tool/ffigen.dart
import 'dart:io';
import 'package:ffigen/ffigen.dart';
   
void main() {
  final packageRoot = Platform.script.resolve('../');
  FfiGenerator(
    // Required. Output path for the generated bindings.
    output: Output(dartFile: packageRoot.resolve('lib/add.g.dart')),
    // Optional. Where to look for header files.
    headers: Headers(entryPoints: [packageRoot.resolve('src/add.h')]),
    // Optional. What functions to generate bindings for.
    functions: Functions.includeSet({'add'}),
  ).generate();
}

Instead of running dart run ffigen and creating a separate YAML configuration file, they suggest creating a script that contains both. This is an incredible idea, as it gives us type safety and the ability to read code comments around properties.

The Dart convention for these scripts is to put them into the tool folder. The same trend can be seen across other tools. Here is an example from the new hooks feature:

import 'package:code_assets/code_assets.dart';
import 'package:hooks/hooks.dart';
import 'package:native_toolchain_c/native_toolchain_c.dart';
   
void main(List<String> args) async {
  await build(args, (input, output) async {
    if (input.config.buildCodeAssets) {
      final builder = CBuilder.library(
        name: 'add',
        assetName: 'add.g.dart',
        sources: ['src/add.c'],
      );
      await builder.run(input: input, output: output);
    }
  });
}

It is best practice for package authors to provide APIs for CLI packages, allowing them to be imported and run programmatically by other CLIs without requiring I/O and shell operations.

Refactoring Bash Script

In my recent article on creating effective CI, I discussed test sharding – splitting tests into separate groups to run in parallel. This is helpful when you have a lot of tests, and they start to waste too much time. To learn more, visit Effective CI for Flutter.

In that article, I used a Bash script to implement test sharding. It works, however, to understand what happens there, you would need to spend too much time. Let's rewrite it in Dart.

Handling Arguments

The script has a few positional arguments:

Read more