East Pole Site

← Back to portfolio
SLT: Liquid TypeScript
2025

Stop wrestling with complex build pipelines just to get TypeScript into your Liquid templates. SLT compiles TypeScript code directly inside your template files using simple script tags, with automatic dependency tracking and zero webpack configuration. You get type safety and modern JavaScript features exactly where you need them, without sacrificing the simplicity that makes template-driven development productive.

liquidshopifytypescript

Introduction

You're building a Shopify theme, Jekyll site, or any other Liquid-powered application. You want to add some interactive behavior with TypeScript - maybe format prices, calculate cart totals, or implement client-side filtering. The conventional approach always seems to force a sacrifice:

  • Write vanilla JavaScript and lose type safety
  • Set up a complex build pipeline and lose simplicity
  • Maintain separate TypeScript files and lose the direct template integration

What if you didn't have to sacrifice anything? SLT compiles TypeScript and injects the resulting JavaScript directly into your Liquid templates. Write your TypeScript in separate files, reference them with a simple data attribute, and SLT handles the rest. No webpack configuration, no bundler setup, no build pipeline complexity. This is precisely what you need when TypeScript's type safety matters but build system overhead doesn't.

Basic Compilation: TypeScript to JavaScript

Create a simple helper function in your TypeScript file:

// src/product-helpers.ts
export function formatPrice(cents: number): string {
  return `$${(cents / 100).toFixed(2)}`;
}

(window as any).formatPrice = formatPrice;

Now drop it directly into your Liquid template:

<!-- templates/product.liquid -->
<script data-slt-src="src/product-helpers.ts">
  // JavaScript will be generated from TypeScript here
</script>

Run SLT:

$ slt build
šŸš€ Starting SLT compilation...
Found 1 TypeScript files to compile
Found 1 Liquid files to update
āœ… Compilation completed successfully
šŸ“¦ Compiled 1 TypeScript file(s)
šŸ“ Updated 1 Liquid file(s)

Your template now contains compiled JavaScript, ready to use:

<script data-slt-src="src/product-helpers.ts">
  (() => {
    // src/product-helpers.ts
    function formatPrice(cents) {
      return `$${(cents / 100).toFixed(2)}`;
    }
    window.formatPrice = formatPrice;
  })();
</script>

That's it. No webpack configuration, no complex build scripts, no separate compilation steps. Your TypeScript is compiled and injected exactly where you placed it in your template.

That's great for single files, but what about development workflow - do I need to manually recompile every time I make a change?

File Watching for Instant Recompilation

The answer is refreshingly simple: you don't need to manually recompile anything. The secret lies in the magic of file watching:

$ slt watch
šŸ‘€ Starting SLT file watcher...
šŸš€ Running initial compilation...
Found 1 TypeScript files to compile
Found 1 Liquid files to update
āœ… Initial compilation completed
šŸ“¦ Compiled 1 TypeScript file(s)
šŸ“ Updated 1 Liquid file(s)
šŸ“” Watching for changes... (Press Ctrl+C to stop)

Edit your TypeScript files or Liquid templates, and SLT automatically recompiles and updates everything. This is development flow as it should be - immediate and frictionless.

This handles development beautifully, but what happens when I need to target different browsers for production?

Targeting JavaScript Versions

SLT handles this through a simple data attribute. You can write modern TypeScript and choose exactly how it gets compiled for different browsers, right from your template files.

Consider this TypeScript code using optional chaining to safely navigate object properties:

// src/customer-info.ts
interface Customer {
  addresses?: Array<{city?: string}>;
  orders?: {last?: {total: number}};
}

export function getCustomerInfo(customer: Customer) {
  // Using optional chaining to safely navigate the object
  const primaryCity = customer.addresses?.[0]?.city;
  const lastOrderTotal = customer.orders?.last?.total;
  
  return { primaryCity, lastOrderTotal };
}

The secret lies in the magic of the data-slt-target attribute. Set it to es2017 for broader browser support:

<script data-slt-src="src/customer-info.ts" data-slt-target="es2017">
  // TypeScript will be compiled to ES2017
</script>

SLT compiles your modern syntax down, generating all the necessary null checks and helper variables:

function getCustomerInfo(customer) {
  var _customer_addresses_, _customer_addresses, _customer_orders_last, _customer_orders;
  var primaryCity = (_customer_addresses = customer.addresses) === null || _customer_addresses === void 0 ? void 0 : (_customer_addresses_ = _customer_addresses[0]) === null || _customer_addresses_ === void 0 ? void 0 : _customer_addresses_.city;
  var lastOrderTotal = (_customer_orders = customer.orders) === null || _customer_orders === void 0 ? void 0 : (_customer_orders_last = _customer_orders.last) === null || _customer_orders_last === void 0 ? void 0 : _customer_orders_last.total;
  
  return { primaryCity: primaryCity, lastOrderTotal: lastOrderTotal };
}

However, if you're targeting modern browsers, change to data-slt-target="es2020":

<script data-slt-src="src/customer-info.ts" data-slt-target="es2020">
  // TypeScript will be compiled to ES2020
</script>

And keep your code clean:

function getCustomerInfo(customer) {
  const primaryCity = customer.addresses?.[0]?.city;
  const lastOrderTotal = customer.orders?.last?.total;
  
  return { primaryCity, lastOrderTotal };
}

As you can tell, the difference is dramatic. The ES2017 output creates a maze of helper variables and null checks that's nearly impossible to read, while the ES2020 output preserves the elegant simplicity of your original TypeScript.

This is precisely why SLT puts the decision in your hands, template by template. Your authentication script might target ES2017 for maximum compatibility, while your admin dashboard uses ES2020 for cleaner code. No complex configuration files - just data attributes that live exactly where the code runs.

Modern syntax with flexible compilation targets is powerful, but how do I actually use Liquid variables inside my TypeScript code?

Type-Safe Liquid Variables in TypeScript

The solution is elegantly simple. You can use liquid template literals to embed Liquid expressions directly in your TypeScript code while maintaining full type safety:

// src/shop-info.ts
const shopName = liquid<string>`{{ shop.name }}`;
const cartItemCount = liquid<number>`{{ cart.item_count }}`;

// Use with full type safety - TypeScript knows cartItemCount is a number
if (cartItemCount > 0) {
  badge.textContent = cartItemCount.toString();
}

After compilation, the liquid templates are replaced with actual Liquid expressions:

var shopName = {{ shop.name }};
var cartItemCount = {{ cart.item_count }};

if (cartItemCount > 0) {
  badge.textContent = cartItemCount.toString();
}

This approach gives you the best of both worlds - TypeScript's compile-time type checking and IDE support, while still using Shopify's Liquid variables directly. No runtime overhead, no string parsing, just clean integration between your TypeScript logic and Liquid data.

That handles individual templates nicely, but what happens when I have multiple files with dependencies - does this scale?

Automatic Dependency Tracking

The answer turns out to be surprisingly straightforward: dependency tracking just works. You don't need to sacrifice code organization just because you're compiling TypeScript into templates. When you change a function buried deep in your dependency tree, SLT automatically propagates that change all the way up to your Liquid templates.

This is precisely the magic you experience in watch mode. Consider this real dependency chain that powers a product card display:

<!-- templates/product-card.liquid -->
<script data-slt-src="src/product-display.ts">
  // Product display logic goes here
</script>

The product-display.ts file imports from a price formatter:

// src/product-display.ts
import { formatProductPrice } from './formatting/price-formatter';

const product = liquid<{price: number}>`{{ product }}`;
const currency = liquid<string>`{{ shop.currency }}`;

export function displayPrice(): string {
  return formatProductPrice(product.price, currency);
}

Which in turn imports from a deep dependency for currency symbols:

// src/formatting/price-formatter.ts
import { getCurrencySymbol } from './currency-symbols';

export function formatProductPrice(cents: number, currency: string): string {
  const symbol = getCurrencySymbol(currency);
  const amount = (cents / 100).toFixed(2);
  return `${symbol}${amount}`;
}

And here's the deepest level - the actual currency symbol lookup:

// src/formatting/currency-symbols.ts
export function getCurrencySymbol(currency: string): string {
  const symbols = {
    USD: "$",
    EUR: "€", 
    GBP: "Ā£",
    CAD: "C$"
  };
  return symbols[currency] || "$";
}

Your compiled template initially contains this JavaScript bundle with all dependencies:

function getCurrencySymbol(currency) {
  const symbols = {
    USD: "$",
    EUR: "€",
    GBP: "Ā£",
    CAD: "C$"
  };
  return symbols[currency] || "$";
}

function formatProductPrice(cents, currency) {
  const symbol = getCurrencySymbol(currency);
  const amount = (cents / 100).toFixed(2);
  return `${symbol}${amount}`;
}

function displayPrice() {
  return formatProductPrice(product.price, currency);
}

Here's where the magic happens. You decide to add support for Japanese Yen and Australian Dollars. You save this change to currency-symbols.ts:

export function getCurrencySymbol(currency: string): string {
  const symbols = {
    USD: "$",
    EUR: "€", 
    GBP: "Ā£",
    CAD: "C$",
    JPY: "Ā„",
    AUD: "A$"
  };
  return symbols[currency] || currency + " ";
}

The secret lies in the magic of automatic dependency tracking. SLT instantly detects your change three levels deep in the dependency tree, recompiles the entire chain, and updates your Liquid template with the new bundle:

function getCurrencySymbol(currency) {
  const symbols = {
    USD: "$",
    EUR: "€",
    GBP: "Ā£",
    CAD: "C$",
    JPY: "Ā„",
    AUD: "A$"
  };
  return symbols[currency] || currency + " ";
}

// ... rest of the bundle with formatProductPrice and displayPrice

As you can tell, this happens instantly in watch mode. You save the deep dependency file, and within milliseconds your Liquid template reflects the change. No manual rebuilding, no hunting for which templates use which dependencies. SLT understands your entire import graph and keeps everything synchronized automatically.

That means you can organize your code exactly how it makes sense: utility functions in their own files, formatting logic properly separated, currency symbols isolated and maintainable. The moment you change any piece of the puzzle, every template that depends on it updates immediately.

Development dependency management is solid, but what about shipping optimized code to production?

Production Builds with Minification

When it's time to ship, SLT handles production builds with the same simplicity. You want minified code targeting the browsers your users actually use, and SLT handles this with simple data attributes:

<script data-slt-src="src/cart.ts" data-slt-minify="true" data-slt-target="es5">
  // TypeScript code will be compiled and minified here
</script>

After compilation, you get minified ES5 code that runs everywhere:

<script data-slt-src="src/cart.ts" data-slt-minify="true" data-slt-target="es5">
  (function(){function o(t){return t.reduce(function(n,a){return n+a.price*a.quantity},0)}window.calculateTotal=o;})();
</script>

No separate build configuration, no environment-specific settings. The compilation options live right alongside the code that uses them.

When SLT Fits Your Project

SLT shines when you're building Shopify themes, Jekyll sites, or any Liquid-powered application where you want TypeScript's benefits without the build system complexity. This is precisely the sweet spot - projects where Liquid templates are already your primary build system.

Remember those sacrifices we talked about? With SLT, you get type safety from TypeScript, simplicity from zero configuration, and direct template integration through automatic compilation. The secret lies in focusing on exactly what template-driven projects need - nothing more, nothing less.

If you're starting a new React or Vue application, you'll want webpack or Vite instead. But for adding type-safe, maintainable JavaScript to template-driven sites, SLT gets out of your way and lets you focus on building features.

It's TypeScript in your templates, finally done right.