g | x | w | all
Bytes Lang Time Link
246PHP7.4 d error_reporting=0241129T144829ZLux
341Haskell230417T185456ZRoman Cz
10805AB1E230411T134550ZKevin Cr
213JavaScript ES6230409T184646ZArnauld
157Retina 0.8.2230410T000049ZNeil
427Mathematica230410T082116Z138 Aspe
091Python + num2words230409T173613ZThe Thon
127Charcoal230409T221723ZNeil

PHP7.4 -d error_reporting=0, 246 bytes

fn($n)=>strtr(($B=[3,ein0,zwei,drei,vier,fuenf,sech1,sieb2,acht,neun,zehn,elf,zwoelf,24=>vierundwanzig])[$n]?:$B[($r=$n-10)>9?$n%10:0].und.strtr($B[$r<10?$r:$n/10],[0,0,0,wei=>wan]).($r<10?zehn:zig),[$B[$n]?s:'',s,en,'3und'=>'','null',izi=>issi])

Explanation:

/* Three big irregularities: ein/eins, sech/sechs, sieb/sieben
   and two small irregularities: vierundwanzig, .*dreissig
   The major irregularities are represented by embedding numerals in the strings,
   and as an implementation detail, we treat the input 0 a bit specially

   Basic idea is performing string translations on the input with a unified code path.
 */
$B=[3,ein0,zwei,drei,vier,fuenf,sech1,sieb2,acht,neun,zehn,elf,zwoelf,24=>vierundwanzig];
$r=$n-10;
fn($n)=>
  strtr( /* strtr#1 */
    /* if $n is a key of $B, then the first argument of strtr#1 is $B[$n] */
    $B[$n]?:
    /* otherwise, compute: $x.und.$y.$z where
        $x = $n < 20 ? '3' : $B[$n % 10]
        $y = strtr($B[$n < 20 ? $n % 10 : $n / 10], ['1'=>'0','2'=>'0','wei'=>'wan'])
        $z = $n < 20 ? 'zehn' : 'zig'
     */
    $B[$r>9?$n%10:0].und
    .strtr($B[$r<10?$r:$n/10],[0,0,0,wei=>wan])
    .($r<10?zehn:zig)
    ,
    /*
     we replace:
       'ein' => $n is a key of $B ? 'eins' : 'ein'
       '1' => 's' (unless it was deleted in the $y computation)
       '2' => 'en' (unless it was deleted in the $y computation)
       '3' => 'null'
       '3und' => '' (so that we don't get nullund.X.zig)
       'izi' => 'issi' (for 'dreissig' case)
     */
    [$B[$n]?s:'',s,en,'3und'=>'','null',izi=>issi]
  )

Haskell, 341 bytes

n 0="null"
n 1="eins"
n 10="zehn"
n 11="elf"
n 2="zwei"
n 12="zwoelf"
n 20="zwanzig"
n 3="drei"
n 30="dreissig"
n 4="vier"
n 5="fuenf"
n 6="sechs"
n 16="sechzehn"
n 60="sechzig"
n 7="sieben"
n 17="siebzehn"
n 70="siebzig"
n 8="acht"
n 9="neun"
n t|t<20=n(t-10)++"zehn"
n t|mod t 10==0=n(div t 10)++"zig"
n t=n(mod t 10)++"und"++n(t-mod t 10)

Try it online!

05AB1E, 108 bytes

'¡Ö.•вß³á∍>jO5:+VÍÏnтågÏÇ&õL1δç´#₄’WH‹fÈ7Pm•#D9ÝÁè©ć«¦¦®¦¦¦.•8WÏ•š…zig«„iz…iss:.•j(}•3ô'z:®¦ć¨š…und«õšδì)˜Iè

Try it online or verify the entire list.

Explanation:

'¡Ö          '# Push dictionary string "null"
.•вß³á∍>jO5:+VÍÏnтågÏÇ&õL1δç´#₄’WH‹fÈ7Pm•
              # Push compressed string
              #  "eins zwei drei vier fuenf sechs sieben acht neun zehn elf zwoelf"
  #           # Split it on spaces to a list
D             # Duplicate this list
 9Ý           # Push a list in the range [0,9]
   Á          # Rotate it to [9,0,1,2,3,4,5,6,7,8]
    è         # 0-based index each in the copy:  ["zehn","eins","zwei","drei","vier",
              #  "fuenf","sechs","sieben","acht","neun"]
     ©        # Store this list in variable `®` (without popping)
      ć       # Extract head; push first item and remainder-list separately
       «      # Append this "zehn" to each item in the remainder-list
        ¦¦    # Remove the first two items ("einszehn" and "zeizehn")
®             # Push list `®` again
 ¦¦¦          # Remove the first three items ("zehn", "eins", and "zei")
    .•8WÏ•š   # Prepend "zwan" to the list
…zig«         # Append "zig" to each item in the list
 „iz…iss:     # Replace all "iz" with "iss"
 .•j(}•       # Push compressed string "enzsz"
       3ô     # Split it into parts of size 3: ["enz","sz"]
         'z: '# Replace both with "z"
              # (with `®¦¦¦.•8WÏ•š…zig«„iz…iss:.•j(}•3ô'z:` we've pushed list 
              #  ["zwanzig","dreissig","vierzig","fuenfzig","sechzig","siebzig",
              #  "achtzig","neunzig"])
®¦            # Push list `®`, and remove the first item "zehn"
  ć           # Extract head "eins"
   ¨          # Remove its last letter "s"
    š         # Prepend "ein" back to the list
     …und«    # Append "und" to each item in the list
          õš  # Prepend an empty string "" to the list
δ             # Apply double-vectorized on the two lists:
 ì            #  Prepend
)             # Wrap everything on the stack into a list
 ˜            # Flatten it
  Iè          # Use the input to 0-based index into it
              # (after which the result is output implicitly)

See this 05AB1E tip of mine (sections How to use the dictionary? and How to compress strings not part of the dictionary?) to understand why '¡Ö is "null"; .•вß³á∍>jO5:+VÍÏnтågÏÇ&õL1δç´#₄’WH‹fÈ7Pm• is "eins zwei drei vier fuenf sechs sieben acht neun zehn elf zwoelf"; .•8WÏ• is "zwan"; and .•j(}• is "enzsz".

JavaScript (ES6), 213 bytes

f=(n,m=8,u=n%10)=>n<20?`null ein5 zw26 drei vier fuenf sech7 sieb89 acht neun zehn elf zwoelf`.replace(/\d/g,n=>['enasisn'[(m^n)%10]]).split` `[~~n]||f(u,0)+'zehn':(u?f(u,2)+'und':'')+f(n/=10,0)+(n^3?'zig':'ssig')

Try it online! (test cases)
Try it online! (all values from 0 to 99)

How?

eins, zwei, sechs and sieben have different forms when they're used as prefixes. To support these alternate forms, some letters are encoded as decimal digits:

ein5 zw26 sech7 sieb89

Given a digit \$n\$ and a parameter \$m\$, the correct letter is obtained with:

[ 'enasisn'[(m ^ n) % 10] ]
// 0123456

The outer brackets are used to coerce the result to an empty string if it's undefined.

We use:

This gives the following table:

 m | n | m^n | %10 | letter
---+---+-----+-----+-----------------
 0 | 5 |  5  |  5  | 's' -> eins (1)
 0 | 2 |  2  |  2  | 'a' \_ zwan (2)
 0 | 6 |  6  |  6  | 'n' /
 0 | 7 |  7  |  7  |  -  -> sech
 0 | 8 |  8  |  8  |  -  \_ sieb
 0 | 9 |  9  |  9  |  -  /
---+---+-----+-----+-----------------
 2 | 5 |  7  |  7  |  -  -> ein
 2 | 2 |  0  |  0  | 'e' \_ zwei
 2 | 6 |  4  |  4  | 'i' /
 2 | 7 |  5  |  5  | 's' -> sechs
 2 | 8 | 10  |  0  | 'e' \_ sieben
 2 | 9 | 11  |  1  | 'n' /
---+---+-----+-----+-----------------
 8 | 5 | 13  |  3  | 's' -> eins
 8 | 2 | 10  |  0  | 'e' \_ zwei
 8 | 6 | 14  |  4  | 'i' /
 8 | 7 | 15  |  5  | 's' -> sechs
 8 | 8 |  0  |  0  | 'e' \_ sieben
 8 | 9 |  1  |  1  | 'n' /

(1) Not used
(2) Used for -zig only

Retina 0.8.2, 162 157 bytes

12
zwo11
11
elf
(\d)(.)
$2und$1zig
3z
3ss
0?und1.+
zehn
0und

0
null
1
eins
su
u
2
zwei
eiz
anz
3
drei
4
vier
5
fuenf
6
sechs
7
sieben
8
acht
9
neun
sz|enz
z

Try it online! Link is to test suite that automatically generates the results for 0-99. Explanation:

12
zwo11
11
elf

Process 12 and 11 first.

(\d)(.)
$2und$1zig

For two digit inputs, switch the digits, insert und, and suffix zig.

3z
3ss

Except dreizig becomes dreissig.

0?und1.+
zehn

nullundeinzig becomes zehn, as does undeinzig when a suffix of any other digit.

0und

0
null

Delete a nullund prefix but a lone 0 becomes null.

1
eins
su
u

1 becomes eins unless it's a prefix in which case it's only ein.

2
zwei
eiz
anz

2 becomes zwei except before zig in which case it becomes zwan.

3
drei
4
vier
5
fuenf
6
sechs
7
sieben
8
acht
9
neun

Translate the remaining digits.

sz|enz
z

Fix up sechszehn, siebenzehn, sechszig and siebenzig to sechzehn, siebzehn, sechzig and siebzig.

Edit: Saved 4 bytes thanks to @Arnauld.

Mathematica, 427 bytes

Golfed version, try it online!

Module[{o,t},o={"null","eins","zwei","drei","vier","fuenf","sechs","sieben","acht","neun"};t={"","","zwanzig","dreissig","vierzig","fuenfzig","sechzig","siebzig","achtzig","neunzig"};Which[0<=n<10,o[[n+1]],10<=n<20,StringJoin[{"zehn","elf","zwoelf","dreizehn","vierzehn","fuenfzehn","sechzehn","siebzehn","achtzehn","neunzehn"}[[n-9]]],20<=n<100,StringJoin[{If[Mod[n,10]!=0,o[[Mod[n,10]+1]]<>"und",""],t[[Quotient[n,10]+1]]}]]]

Ungolfed version

germanNumber[n_Integer] := Module[{ones, tens},
  ones = {"null", "eins", "zwei", "drei", "vier", "fuenf", "sechs", "sieben", "acht", "neun"};
  tens = {"", "", "zwanzig", "dreissig", "vierzig", "fuenfzig", "sechzig", "siebzig", "achtzig", "neunzig"};

  Which[
   0 <= n < 10, ones[[n + 1]],
   10 <= n < 20, StringJoin[{"zehn", "elf", "zwoelf", "dreizehn", "vierzehn", "fuenfzehn", "sechzehn", "siebzehn", "achtzehn", "neunzehn"}[[n - 9]]],
   20 <= n < 100, StringJoin[{If[Mod[n, 10] != 0, ones[[Mod[n, 10] + 1]] <> "und", ""], tens[[Quotient[n, 10] + 1]]}]
   ]
  ]

Python + num2words, 54 107 104 91 bytes

lambda n:num2words(n,0,'de').translate({252:'ue',246:'oe',223:'ss'})
from num2words import*

+53 bytes because we have to replace non-ASCII with ASCII equivalents.
-3 thanks to @CreativeName
-13 thanks to @Neil

Charcoal, 127 bytes

≔E⪪”&⌈⪪γ,‴\Π↶QBσPc�⁸QDι,ÀH⁸²V≧τ⁼θCUH↶⊙↨H⊖O³j⪫JWX<⸿Dη″β↓”p⪪ιqζNθ≔﹪θχη¿‹θ¹³⁺⌈§ζθ×s⁼θ¹¿‹θ²⁰⁺⌊§ζη⌈§ζχ«∧η⁺⌈§ζηund⌊§ζ÷θχ⎇⁼³÷θχss¦z¦ig

Try it online! Link is to verbose version of code. Explanation:

≔E⪪”...”p⪪ιqζ

Split a compressed string on p and then split each substring on q (for the numbers 2, 6 and 7).

Nθ≔﹪θχη

Input the number in base 10 and take the remainder modulo 10.

¿‹θ¹³

If the number is less than 13, then...

⁺⌈§ζθ×s⁼θ¹

... output the maximum of the two substrings for that number, plus an extra s if the number is 1.

¿‹θ²⁰

Otherwise, if the number is less than 20, then...

⁺⌊§ζη⌈§ζχ

... output the minimum of the two substrings for that number modulo 10, plus the substring for 10 (although I could have just used the string literal at this point).

«

Otherwise:

∧η⁺⌈§ζηund

If the remainder modulo 10 is not zero, then output the maximum of the two substrings for that number, plus the literal string und.

⌊§ζ÷θχ

Output the minimum of the two substrings for the number integer divided by 10. This outputs zwan for 20-29, sech for 60-69, and sieb for 70-79.

⎇⁼³÷θχss¦z¦ig

Output ssig if the number is in the range 30-39 otherwise output zig.

It looks as if the alternate forms are just the first four letters of the regular form, but this only works if you use the original accented characters, which Charcoal can't compress anyway, and working around the cases of 15 and 50-59 just takes too many bytes.