Inline Tests: Test Private Functions

A TJS file can test its own helpers without exporting them or adding a test-only export.

A test '…' { } block runs inside the module it is written in, so it can call anything that module declares: exported or not. In most JavaScript setups a test file imports the module under test, which means it can only reach what the module exports, and internal helpers end up exported "for testing" or tested only through the public API.

Tests run when the file is transpiled, not in a separate test step. The transpiler executes the module's own top-level code, runs the tests against it, and leaves the tests out of the JavaScript it emits.

The example below is a small sign-up module. Five helpers are private; only createUser is exported. Every helper is tested directly. Two pieces of TJS syntax carry most of it:

The syntax reference covers both. Inside a test block, expect is built in, with Jest-style matchers (toBe, toEqual, toContain and others; the full list is in the reference under "Test blocks").

// ============================================================
// Private helpers (not exported)
// ============================================================

const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/

// The return annotation is a worked example, and it is tested:
// the transpiler calls isValidEmail('') and checks the result is false.
// An empty string is not a valid address, so false is the right answer.
function isValidEmail(email: ''): false {
  return EMAIL_REGEX.test(email)
}

// Tested the same way: sanitize('') must return ''.
function sanitize(input: ''): '' {
  return input.trim().toLowerCase()
}

// The result is random, so no fixed example can match it.
// `:!` keeps the return as documentation without testing it.
function generateId(prefix: 'user'):! '' {
  return prefix + '_' + Math.random().toString(36).slice(2, 10)
}

// A toy hash, for the demo only. Never hash real passwords like this.
// The worked example is exact: the empty string never enters the
// loop, so the hash stays 0 and the result is 'hashed_0'.
function hashPassword(password: ''): 'hashed_0' {
  let hash = 0
  for (let i = 0; i < password.length; i++) {
    hash = ((hash << 5) - hash) + password.charCodeAt(i)
    hash = hash & hash // force to a 32-bit integer
  }
  return 'hashed_' + Math.abs(hash).toString(16)
}

// The return shows the SHAPE (a flag plus a list of messages), not the
// answer for '' (which is strong: false with four messages), so `:!`
// keeps it as documentation instead of testing it.
function isStrongPassword(password: ''):! { strong: true, issues: [''] } {
  const issues = []
  if (password.length < 8) issues.push('Must be at least 8 characters')
  if (!/[A-Z]/.test(password)) issues.push('Must contain uppercase letter')
  if (!/[a-z]/.test(password)) issues.push('Must contain lowercase letter')
  if (!/[0-9]/.test(password)) issues.push('Must contain a number')
  return { strong: issues.length === 0, issues }
}

// ============================================================
// Public API (the only export)
// ============================================================

// `Type` declares a named type from an example. Examples widen:
// `code: 400` means "an integer", not "exactly 400".
Type NewUser = { id: '', email: '', passwordHash: '' }
Type Failure = { error: '', code: 400 }

// The annotation names two possible shapes, not one result, so it is
// `:!`: createUser({ email: '', password: '' }) returns a Failure.
export function createUser(input: { email: '', password: '' }):! NewUser | Failure {
  const cleanEmail = sanitize(input.email)
  if (!isValidEmail(cleanEmail)) {
    return { error: 'Invalid email format', code: 400 }
  }

  const strength = isStrongPassword(input.password)
  if (!strength.strong) {
    return { error: strength.issues.join(', '), code: 400 }
  }

  return {
    id: generateId('user'),
    email: cleanEmail,
    passwordHash: hashPassword(input.password)
  }
}

// ============================================================
// Tests: they call the private helpers directly
// ============================================================

test 'isValidEmail accepts valid emails' {
  expect(isValidEmail('test@example.com')).toBe(true)
  expect(isValidEmail('user.name+tag@domain.co.uk')).toBe(true)
}

test 'isValidEmail rejects invalid emails' {
  expect(isValidEmail('not-an-email')).toBe(false)
  expect(isValidEmail('@nodomain.com')).toBe(false)
  expect(isValidEmail('spaces in@email.com')).toBe(false)
}

test 'sanitize trims and lowercases' {
  expect(sanitize('  HELLO  ')).toBe('hello')
  expect(sanitize('  Test@Email.COM  ')).toBe('test@email.com')
}

test 'generateId creates prefixed, distinct IDs' {
  const id1 = generateId('user')
  const id2 = generateId('user')
  expect(id1.startsWith('user_')).toBe(true)
  expect(id1 !== id2).toBe(true)
  expect(generateId('post').startsWith('post_')).toBe(true)
}

test 'hashPassword is deterministic' {
  expect(hashPassword('secret123')).toBe(hashPassword('secret123'))
}

test 'hashPassword gives different inputs different hashes' {
  expect(hashPassword('password1') !== hashPassword('password2')).toBe(true)
}

test 'isStrongPassword accepts a strong password' {
  const result = isStrongPassword('MyStr0ngP@ss!')
  expect(result.strong).toBe(true)
  expect(result.issues.length).toBe(0)
}

test 'isStrongPassword names each problem' {
  expect(isStrongPassword('lowercase123').issues).toContain('Must contain uppercase letter')
  expect(isStrongPassword('UPPERCASE123').issues).toContain('Must contain lowercase letter')
  expect(isStrongPassword('NoNumbers!').issues).toContain('Must contain a number')
  expect(isStrongPassword('Ab1!').issues).toContain('Must be at least 8 characters')
}

// The public function is tested too, end to end.
test 'createUser rejects a bad email' {
  const result = createUser({ email: 'invalid', password: 'StrongPass1!' })
  expect(result.error).toBe('Invalid email format')
}

test 'createUser rejects a weak password' {
  const result = createUser({ email: 'test@test.com', password: 'weak' })
  expect(result.code).toBe(400)
}

test 'createUser cleans the email and hashes the password' {
  const result = createUser({ email: '  Test@Example.COM  ', password: 'MyStr0ngPass!' })
  expect(result.email).toBe('test@example.com')
  expect(result.passwordHash.startsWith('hashed_')).toBe(true)
}

// ============================================================
// Demo (top-level code, so it also runs when the tests do; harmless
// here, and it gives the live example some output)
// ============================================================

console.log(createUser({ email: 'demo@example.com', password: 'SecurePass123!' }))
// { id: 'user_…' (random), email: 'demo@example.com', passwordHash: 'hashed_…' }
console.log(createUser({ email: 'demo@example.com', password: 'short' }))
// { error: 'Must be at least 8 characters, Must contain uppercase letter, Must contain a number', code: 400 }

What to notice

When a test fails

A failing test stops the transpile: the file does not compile, so on this page the example shows the error instead of running. Here is a helper with a bug:

function slugify(title: 'Hello World'): 'hello-world' {
  return title.toLowerCase().split(' ').join('-')
}

test 'slugify ignores surrounding spaces' {
  expect(slugify('  Hi there ')).toBe('hi-there')
}

The signature example passes, because 'Hello World' has no stray spaces. The test block catches what the example misses:

Expected "hi-there" but got "--hi-there-"

From the command line, a failing test fails tjs check (exit 1) and blocks tjs emit (unless you pass --jfdi), but tjs run does not run tests at all, so a clean run is not proof that a file is correct. Running TJS has the details for each command.

Fix the helper and both kinds of test pass:

function slugify(title: 'Hello World'): 'hello-world' {
  return title.trim().toLowerCase().split(/\s+/).join('-')
}

test 'slugify ignores surrounding spaces' {
  expect(slugify('  Hi there ')).toBe('hi-there')
}

test 'slugify collapses runs of whitespace' {
  expect(slugify('Inline   tests')).toBe('inline-tests')
}

console.log(slugify('  Test the Helpers   Ship the API  ')) // test-the-helpers-ship-the-api

Limits